Skip to content

Server rendering ​

FormRenderer and FormhausForm render on the server and hydrate on the client without mismatches.

What runs where ​

  • @formhaus/core does not touch the DOM. The engine, validateSubmission() and fetchForm() run in Node, server components and route handlers.
  • Field ids come from React useId and Vue useId. 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 source render their defaultValue on 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:

tsx
import { definition } from '@/form/definition';
import { SignupForm } from './signup-form';

export default function Page() {
  return (
    <main>
      <h1>{definition.title}</h1>
      <SignupForm definition={definition} />
    </main>
  );
}
tsx
'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:

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="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:

tsx
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:

vue
<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, localStorage or the current time in a custom field component during render. Read them in useEffect or onMounted.
  • initialValues that differ between the server and the client, for example values from localStorage. Pass the same values on both sides, or set them after mount with the setValue handle.
  • 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 own idPrefix, or use Vue 3.5.