Next.js App Router
A server component loads the definition, a client component renders it with @formhaus/react, and a route handler validates the submitted values again with FormEngine from @formhaus/core.
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 to the route handler. A 422 response carries field errors, which go to the errors prop. A 400 response carries a message, shown above the form.
'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> }
| { message: string };
export function SignupForm({ definition }: { definition: FormDefinition }) {
const [errors, setErrors] = useState<Record<string, string>>();
const [message, setMessage] = useState<string | null>(null);
const [saved, setSaved] = useState<Record<string, unknown> | null>(null);
async function submit(values: Record<string, unknown>) {
const response = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
const result = (await response.json()) as SubmitResponse;
setMessage('message' in result ? result.message : null);
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 (
<>
{message && <p role="alert">{message}</p>}
<FormRenderer definition={definition} errors={errors} onSubmit={submit} />
</>
);
}Route handler
import { FormEngine } from '@formhaus/core';
import { definition } from '@/form/definition';
import { parseValues } from '@/form/parse-values';
import { serverValidators } from '@/form/validators';
export async function POST(request: Request) {
const values = parseValues(await request.json().catch(() => null));
if (!values) {
return Response.json({ message: 'Expected an object of string, number or boolean values' }, { status: 400 });
}
const engine = new FormEngine(definition, values, { validators: serverValidators });
const errors = engine.validate();
if (Object.keys(errors).length > 0) {
return Response.json({ errors }, { status: 422 });
}
return Response.json({ values: engine.getSubmitValues() });
}How server re-validation works
parseValuesrejects a body that is not an object of string, number, boolean or string array values. The route handler returns400with amessage, and the page shows it above the form.- The route handler builds a new
FormEnginefrom the same definition and the posted values. - The engine computes the active path from the values. Fields on skipped branches are not validated: a Personal submission is not checked for
company. validate()checks every visible field on the active path and returns{ [fieldKey]: message }.getSubmitValues()drops values that are not on the active path, so a client cannot sendcompanywith the Personal plan.- When
FormRendererreceives errors through theerrorsprop, it moves to the first step with an error and shows the messages there.
The companyAvailable validator is passed only on the server. The client has no validator with that name, so the rule is skipped in the browser and enforced in the route handler:
import type { ValidatorFn } from '@formhaus/core';
const takenCompanies = new Set(['acme']);
export const serverValidators: Record<string, ValidatorFn> = {
companyAvailable: (value) =>
takenCompanies.has(String(value).trim().toLowerCase()) ? 'This company already has a workspace' : null,
};The type guard:
function isFieldValue(value: unknown): boolean {
if (Array.isArray(value)) return value.every((item) => typeof item === 'string');
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
}
export function parseValues(body: unknown): Record<string, unknown> | null {
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
return Object.values(body).every(isFieldValue) ? (body as Record<string, unknown>) : null;
}FormEngine does not coerce types: "5" sent for a number field is validated as a string. The route handler does not limit the request body size; set a limit in your proxy or hosting platform.
Choose the Team plan and enter Acme as the company to see the server error on the company step.