Nuxt
A Nuxt page renders the definition with @formhaus/vue, and a server route validates the submitted values again with FormEngine from @formhaus/core.
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> }
| { 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.
<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
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
parseValuesrejects a body that is not an object of string, number, boolean or string array values. The server route returns400with amessage, and the page shows it above the form.- The server route builds a new
FormEnginefrom 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
FormRendererreceives errors through theerrorsprop, 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:
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:
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.