SSR and Next.js
formstand is client state — a zustand store driving controlled inputs. Server rendering interacts with that in exactly three places: where the form object is created, what its initial values are, and when persistence runs. Get those right and the rest is ordinary React.
The rule of thumb
Use useForm inside a "use client" component. That's it for most apps:
"use client";
import { useForm, TextField } from "formstand";
import { profileSchema } from "./profileSchema";
export function ProfileForm() {
const form = useForm(profileSchema, { initialValues: { name: "" } });
return <TextField form={form} path="name" label="Name" />;
}useForm creates the store per component instance — every request's render gets its own, and nothing is shared across users. Server components can't call hooks at all, so the boundary is enforced by Next itself: put the form in a client component, pass server data down as props.
Why createFormHooks needs care on the server
createFormHooks(form, name) is built on a module-scope singleton — that's its whole point in an SPA: one form, importable hooks, no provider. On a server, module scope is shared across requests in the same process:
// hooks.ts — module scope: ONE store per server process, not per request
export const profileForm = createForm(profileSchema, { initialValues });
export const { useProfileField } = createFormHooks(profileForm, "profile");During SSR, every concurrent request rendering this form reads (and could write) the same store. For a form that only ever renders its initial values on the server — the normal case, since users type on the client — the practical exposure is small: the server render reads pristine initial state, and hydration hands over to a client-side store that behaves like the SPA case. But two things must stay true:
- Never write to the form during render or in server code. A
setValueon the server mutates state visible to other requests. - Initial values must be static. Anything per-request (the logged-in user's data) must NOT be baked into the module singleton.
When either of those doesn't hold, don't use the singleton on the server. The alternatives:
Per-request data: useForm + createFormContext
The provider pattern gives you the module's ergonomics with per-mount lifetime:
"use client";
const { FormProvider, useFormContext } = createFormContext<typeof profileSchema>();
export function ProfileFormRoot({ initial }: { initial: ProfileValues }) {
const form = useForm(profileSchema, { initialValues: initial });
return <FormProvider form={form}>{/* fields use useFormContext() */}</FormProvider>;
}Server component fetches → passes initial as a prop → each request/mount gets its own store, and the subtree still avoids prop-drilling.
Client-only singletons: next/dynamic with ssr: false
If you want createFormHooks exactly as-is, render its consumer client-only:
const OnboardingForm = dynamic(() => import("./OnboardingForm"), { ssr: false });No server render, no shared-module concern — at the cost of the form not being in the initial HTML.
Hydration checklist
Deterministic initial values.
new Date(),Math.random(), or locale-formatted strings ininitialValuesrender differently on server and client — a hydration mismatch. Compute per-request values on the server and pass them in, or set them in an effect after mount.Persistence runs in effects.
localStoragedoesn't exist on the server. CallpersistForm(or any storage access) insideuseEffect, never during render:tsxuseEffect(() => { const handle = persistForm(form, { key: "profile-draft" }); return handle.dispose; }, [form]);focusField/focusFirstErrorare DOM calls. Safe to import anywhere (they touch the DOM only when called), but call them from event handlers or effects.The generated modules are client modules.
formstand-gen --layout moduleoutput usescreateFormHooks— add"use client"at the top of the module's files when dropping one into an App Router project, and treat it per the singleton rules above.