Next.js App Router
A server component loads the definition, a client component renders it with @formhaus/react, and a route handler validates the submission with validateSubmission from @formhaus/core/server.
Install
npm install @formhaus/core @formhaus/reactDefinition
The Team plan routes to company details, the Personal plan to a name step. Both branches end at the confirm step.
{
"id": "workspace-signup",
"title": "Create a workspace",
"submit": { "label": "Create workspace" },
"steps": [
{
"id": "account",
"title": "Account",
"fields": [
{
"key": "email",
"type": "email",
"label": "Work email",
"validation": { "required": true, "pattern": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", "patternMessage": "Enter a valid email" }
},
{
"key": "plan",
"type": "radio",
"label": "Plan",
"validation": { "required": true },
"options": [
{ "value": "personal", "label": "Personal" },
{ "value": "team", "label": "Team" }
]
}
],
"routes": [
{ "to": "team", "show": [{ "field": "plan", "eq": "team" }] },
{ "to": "profile" }
]
},
{
"id": "team",
"title": "Team details",
"fields": [
{
"key": "company",
"type": "text",
"label": "Company",
"helperText": "\"Acme\" is taken and fails on the server.",
"validation": { "required": true, "minLength": 2, "validator": "companyAvailable" }
},
{
"key": "teamSize",
"type": "select",
"label": "Team size",
"placeholder": "Select a size",
"validation": { "required": true },
"options": [
{ "value": "2-10", "label": "2 to 10" },
{ "value": "11-50", "label": "11 to 50" },
{ "value": "51+", "label": "51 or more" }
]
}
],
"routes": [{ "to": "confirm" }]
},
{
"id": "profile",
"title": "About you",
"fields": [
{ "key": "name", "type": "text", "label": "Full name", "validation": { "required": true } }
]
},
{
"id": "confirm",
"title": "Confirm",
"fields": [
{
"key": "terms",
"type": "checkbox",
"label": "I accept the terms of service",
"validation": { "required": "Accept the terms to continue" }
}
]
}
]
}The definition module casts the JSON import to FormDefinition so the page and the route handler share one typed object:
import type { FormDefinition } from '@formhaus/core';
import json from './definition.json';
export const definition = json as FormDefinition;Server component
page.tsx has no 'use client' directive. The definition is serialized into the client component's props.
import { definition } from '@/form/definition';
import { SignupForm } from './signup-form';
export default function Page() {
return (
<main>
<h1>{definition.title}</h1>
<SignupForm definition={definition} />
</main>
);
}Import @formhaus/core/style.css in the root layout for the default field styles:
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import '@formhaus/core/style.css';
import './globals.css';
export const metadata: Metadata = {
title: 'Formhaus + Next.js',
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}Client component
FormRenderer uses React state, so it runs in a client component. onSubmit posts the values and skipped step ids to the route handler. A 422 response carries field errors, which go to the errors prop.
'use client';
import type { FormDefinition } from '@formhaus/core';
import { FormRenderer } from '@formhaus/react';
import { useState } from 'react';
type SubmitResponse =
| { values: Record<string, unknown> }
| { errors: Record<string, string> };
export function SignupForm({ definition }: { definition: FormDefinition }) {
const [errors, setErrors] = useState<Record<string, string>>();
const [saved, setSaved] = useState<Record<string, unknown> | null>(null);
async function submit(values: Record<string, unknown>, skippedSteps?: string[]) {
const response = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values, skippedSteps }),
});
const result = (await response.json()) as SubmitResponse;
if ('errors' in result) setErrors(result.errors);
if ('values' in result) setSaved(result.values);
}
if (saved) return <pre>{JSON.stringify(saved, null, 2)}</pre>;
return <FormRenderer definition={definition} errors={errors} onSubmit={submit} />;
}Route handler
import { validateSubmission } from '@formhaus/core/server';
import { definition } from '@/form/definition';
import { runServerValidators } from '@/form/validators';
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const { values, errors } = validateSubmission(definition, body?.values, { skippedSteps: body?.skippedSteps });
runServerValidators(values, errors);
if (Object.keys(errors).length > 0) {
return Response.json({ errors }, { status: 422 });
}
return Response.json({ values });
}How server validation works
- The client posts
{ values, skippedSteps }.skippedStepscomes from the second argument of the submit handler. validateSubmission()from@formhaus/core/servercomputes the active path from the values. Fields on hidden steps, hidden fields and skipped steps are not validated and not returned.- It drops unknown keys, checks value types,
optionsand theemail,dateanddatetimeformats, then runs the definition rules. - It returns
{ values, errors }. A non-emptyerrorsbecomes a422. - When
FormRendererreceives errors through theerrorsprop, it moves to the first step with an error and shows the messages there.
validateSubmission() ignores named validators. The route handler runs them on the returned values. The client has no companyAvailable validator, so the rule is skipped in the browser and enforced on the server:
import type { ValidatorFn } from '@formhaus/core';
import { definition } from './definition';
const takenCompanies = new Set(['acme']);
const serverValidators: Record<string, ValidatorFn> = {
companyAvailable: (value) =>
takenCompanies.has(String(value).trim().toLowerCase()) ? 'This company already has a workspace' : null,
};
export function runServerValidators(values: Record<string, unknown>, errors: Record<string, string>) {
const fields = definition.steps?.flatMap((step) => step.fields) ?? definition.fields ?? [];
for (const field of fields) {
const validate = serverValidators[field.validation?.validator ?? ''];
const value = values[field.key];
if (!validate || errors[field.key] || value === undefined || value === '') continue;
const error = validate(value, values);
if (error) errors[field.key] = error;
}
}The route handler does not limit the request body size; set a limit in your proxy or hosting platform. See Server-side validation for the full list of checks.
Choose the Team plan and enter Acme as the company to see the server error on the company step.