Skip to content

Nuxt ​

A Nuxt page renders the definition with @formhaus/vue, and a server route validates the submission with validateSubmission from @formhaus/core/server.

Open in StackBlitz · Source

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> };

Page ​

The awaited @submit handler keeps the form in its loading state until the request finishes. ignoreResponseError makes $fetch return the 422 body instead of throwing. Field errors go to the errors prop.

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

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

async function submit(values: Record<string, unknown>, skippedSteps?: string[]) {
  const result = await $fetch<SubmitResponse>('/api/submit', {
    method: 'POST',
    body: { values, skippedSteps },
    ignoreResponseError: true,
  });
  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>
    <FormRenderer v-else :definition="definition" :errors="errors" :submit-handler="submit" />
  </main>
</template>

Server route ​

ts
import { validateSubmission } from '@formhaus/core/server';
import { definition } from '~~/shared/definition';
import { runServerValidators } from '../validators';

export default defineEventHandler(async (event) => {
  const body = await readBody(event).catch(() => null);
  const { values, errors } = validateSubmission(definition, body?.values, { skippedSteps: body?.skippedSteps });
  runServerValidators(values, errors);
  if (Object.keys(errors).length > 0) {
    setResponseStatus(event, 422);
    return { errors };
  }

  return { 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 server route 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 '~~/shared/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 server route 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