Server rendering
FormRenderer and FormhausForm render on the server and hydrate on the client without mismatches.
What runs where
@formhaus/coredoes not touch the DOM. The engine,validateSubmission()andfetchForm()run in Node, server components and route handlers.- Field ids come from React
useIdand VueuseId. On Vue 3.3 and 3.4 the ids come from the component's position in the app, so the server and the client get the same ids. - Hidden fields with a
sourcerender theirdefaultValueon the server. Page values such as the query string, referrer, language and time zone are read after hydration. - Focus, the bot check and draft restore run after mount.
Next.js App Router
The server component loads the definition. FormRenderer takes an onSubmit function, so it goes in a 'use client' component:
import { definition } from '@/form/definition';
import { SignupForm } from './signup-form';
export default function Page() {
return (
<main>
<h1>{definition.title}</h1>
<SignupForm definition={definition} />
</main>
);
}'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> };
export function SignupForm({ definition }: { definition: FormDefinition }) {
const [errors, setErrors] = useState<Record<string, string>>();
const [saved, setSaved] = useState<Record<string, unknown> | null>(null);
async function submit(values: Record<string, unknown>, skippedSteps: string[]) {
const response = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values, skippedSteps }),
});
const result = (await response.json()) as SubmitResponse;
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 <FormRenderer definition={definition} errors={errors} onSubmit={submit} />;
}The definition can also come from a database or an API. Fetch it in the server component and pass it as a prop. Full example with the route handler: Next.js App Router.
Nuxt
Nuxt renders pages on the server by default. FormRenderer needs no wrapper:
<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="submit" />
</main>
</template>Full example with the server route: Nuxt.
Cloud forms
Without initialForm, FormhausForm renders its fallback on the server and loads the form in the browser. Load the form on the server with fetchForm() and pass it as initialForm. The server HTML then holds the whole form, and the browser makes no request for it.
Next.js: @formhaus/react/cloud is a client module, so a server component renders FormhausForm directly:
import { fetchForm } from '@formhaus/core/cloud';
import { FormhausForm } from '@formhaus/react/cloud';
export const revalidate = 60;
export default async function Page() {
const form = await fetchForm({ id: 'YOUR_FORM_ID' }).catch(() => undefined);
return <FormhausForm id="YOUR_FORM_ID" initialForm={form} />;
}Nuxt: useAsyncData sends the loaded form to the client with the page payload:
<script setup lang="ts">
import { fetchForm } from '@formhaus/core/cloud';
import { FormhausForm } from '@formhaus/vue/cloud';
const { data: form } = await useAsyncData('contact-form', () => fetchForm({ id: 'YOUR_FORM_ID' }).catch(() => null));
</script>
<template>
<FormhausForm id="YOUR_FORM_ID" :initial-form="form ?? undefined" />
</template>If the load fails, initialForm is empty and the form loads in the browser. revalidate keeps a cached Next.js page within a minute of the published version.
What to avoid
- Reading
window,document,localStorageor the current time in a custom field component during render. Read them inuseEffectoronMounted. initialValuesthat differ between the server and the client, for example values fromlocalStorage. Pass the same values on both sides, or set them after mount with thesetValuehandle.- Formatting dates or numbers with the default locale in a custom component. The server locale can differ from the browser locale. Pass an explicit locale.
- Two server-rendered Vue apps on one page. Give each app its own
app.config.idPrefix, so the field ids do not collide. - On Vue 3.3 and 3.4, a form inside an async component or
<Suspense>hydrates late, and the field ids of the other forms in the same app shift. Mount such a form as its own app with its ownidPrefix, or use Vue 3.5.