---
url: https://formhaus.dev/guide/integrations/nuxt.md
description: >-
  Use Formhaus with Nuxt: render a JSON form definition on a page with
  @formhaus/vue and re-validate the submission in a Nitro server route with
  FormEngine.
---

# Nuxt

A Nuxt page renders the definition with `@formhaus/vue`, and a server route validates the submitted values again with `FormEngine` from `@formhaus/core`.

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

## Install

```bash
npm install @formhaus/core @formhaus/vue
```

Add the default field styles in `nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  compatibilityDate: '2026-10-01',
  css: ['@formhaus/core/style.css', '~/assets/main.css'],
  app: {
    head: { title: 'Formhaus + Nuxt' },
  },
});

```

## Definition

The definition lives in `shared/`, so the page and the server route import the same file. The Team plan routes to company details, the Personal plan to a name 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" }
        }
      ]
    }
  ]
}

```

```ts
import type { FormDefinition } from '@formhaus/core';
import json from './definition.json';

export const definition = json as FormDefinition;

export type SubmitResponse =
  | { values: Record<string, unknown> }
  | { errors: Record<string, string> }
  | { message: string };

```

## Page

`submitHandler` keeps the form in its loading state until the request finishes. `ignoreResponseError` makes `$fetch` return the `400` and `422` bodies instead of throwing. Field errors go to the `errors` prop, and a `message` is shown above the form.

```vue
<script setup lang="ts">
import { FormRenderer } from '@formhaus/vue';
import { definition, type SubmitResponse } from '~~/shared/definition';

const errors = ref<Record<string, string>>();
const message = ref<string | null>(null);
const saved = ref<Record<string, unknown> | null>(null);

async function submit(values: Record<string, unknown>) {
  const result = await $fetch<SubmitResponse>('/api/submit', {
    method: 'POST',
    body: values,
    ignoreResponseError: true,
  });
  message.value = 'message' in result ? result.message : null;
  if ('errors' in result) errors.value = result.errors;
  if ('values' in result) saved.value = result.values;
}
</script>

<template>
  <main>
    <h1>{{ definition.title }}</h1>
    <pre v-if="saved">{{ JSON.stringify(saved, null, 2) }}</pre>
    <template v-else>
      <p v-if="message" role="alert">{{ message }}</p>
      <FormRenderer :definition="definition" :errors="errors" :submit-handler="submit" />
    </template>
  </main>
</template>

```

## Server route

```ts
import { FormEngine } from '@formhaus/core';
import { definition } from '~~/shared/definition';
import { parseValues } from '../parse-values';
import { serverValidators } from '../validators';

export default defineEventHandler(async (event) => {
  const values = parseValues(await readBody(event).catch(() => null));
  if (!values) {
    setResponseStatus(event, 400);
    return { message: 'Expected an object of string, number or boolean values' };
  }

  const engine = new FormEngine(definition, values, { validators: serverValidators });
  const errors = engine.validate();
  if (Object.keys(errors).length > 0) {
    setResponseStatus(event, 422);
    return { errors };
  }

  return { 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 server route returns `400` with a `message`, and the page shows it above the form.
* The server route 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.
* 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, so the rule is skipped in the browser and enforced in the server route:

```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 server route 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.
