Nuxt
A Nuxt page renders the definition with @formhaus/vue, and a server route validates the submission with validateSubmission from @formhaus/core/server.
Install
npm install @formhaus/core @formhaus/vueAdd the default field styles in nuxt.config.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.
{
"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" }
}
]
}
]
}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.
<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
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 }.skippedStepscomes from the second argument of the submit handler. validateSubmission()from@formhaus/core/servercomputes 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,
optionsand theemail,dateanddatetimeformats, then runs the definition rules. - It returns
{ values, errors }. A non-emptyerrorsbecomes a422. - When
FormRendererreceives errors through theerrorsprop, 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:
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.