---
url: https://formhaus.dev/guide/integrations/nextjs.md
description: >-
  Use Formhaus with the Next.js App Router: load the JSON definition in a server
  component, render it in a client component, re-validate in a route handler.
---

# 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`.

[Open in StackBlitz](https://stackblitz.com/github/ignsm/formhaus/tree/main/examples/nextjs-app-router) · [Source](https://github.com/ignsm/formhaus/tree/main/examples/nextjs-app-router)

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

```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> }
  | { 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

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

* `parseValues` rejects a body that is not an object of string, number, boolean or string array values. The route handler returns `400` with a `message`, and the page shows it above the form.
* The route handler builds a new `FormEngine` from 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 send `company` with the Personal plan.
* When `FormRenderer` receives errors through the `errors` prop, 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:

```ts
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:

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