Skip to content

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.

Open in StackBlitz · Source

Install ​

bash
npm install @formhaus/core @formhaus/react

Definition ​

The Team plan routes to company details, the Personal plan to a name step. Both branches end at the confirm step.

json
{
  "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:

ts
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.

tsx
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:

tsx
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.

tsx
'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 ​

ts
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 }. skippedSteps comes from the second argument of the submit handler.
  • validateSubmission() from @formhaus/core/server computes 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, options and the email, date and datetime formats, then runs the definition rules.
  • It returns { values, errors }. A non-empty errors becomes a 422.
  • When FormRenderer receives errors through the errors prop, 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:

ts
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.

Formhaus is MIT licensed. Legal