ADR-0013 Forms
Company standard forms — TanStack Form with shared Zod schemas, shadcn/ui Field components, and POST server functions; JavaScript required by default.
- Decision
- TanStack Form + Zod + shadcn/ui FieldBuild the app
- Status
- Accepted
- Date
- Scope
- Company-wide default for all new apps
Connections
Builds on
Context
Section titled “Context”Every product has forms: sign-in, settings, create/edit screens, multi-step flows. The surrounding decisions are made:
- Validation: Zod 4, one schema per shape, shared between client and server (ADR-0005) — which already assumes TanStack Form
- Components: shadcn/ui on Base UI (ADR-0011); shadcn/ui’s
Fieldcomponents (Field,FieldLabel,FieldDescription,FieldError) ship guides for TanStack Form and React Hook Form - Writes:
POSTserver functions called fromuseMutation, then invalidation (ADR-0012); every write passes the rate limiter (ADR-0010) - Auth forms call Better Auth’s client SDK (ADR-0009), which needs JavaScript
What is open: the form-state library, how errors reach the user, and whether forms must work before JavaScript loads.
Two Start facts shape the answer:
- Server functions are endpoints. Anyone can call them directly, not only our form — so the server validates and checks auth on every call; client validation is UX, never protection
- CSRF protection for server functions comes from
createCsrfMiddleware(), which checksSec-Fetch-Site,Origin, andReferer. Start installs it automatically only when an app has nosrc/start.ts— and every app has one, for the rate limiter (ADR-0010)
Options considered
Section titled “Options considered”1. TanStack Form
Section titled “1. TanStack Form”Pros
- Same maintainers as Router, Query, and Start; MIT; 1.x stable (1.33.5)
- Official Start adapter (
@tanstack/react-form-start) for server validation and no-JS submissions when we need them - Zod 4 schemas plug in directly through Standard Schema — no resolver package
createFormHookbinds our own field components once; forms then useform.AppFieldwith typed field names — a typo is a compile error- Fine-grained subscriptions: a keystroke re-renders the field, not the form
Cons
- Smaller ecosystem and fewer examples than React Hook Form
- Render-prop API (
children={(field) => …}) is more verbose withoutcreateFormHook - v2 is in alpha; a major upgrade will come
2. React Hook Form
Section titled “2. React Hook Form”Pros
- The largest form ecosystem and most examples; shadcn/ui has a guide for it; 7.x stable (7.90.0)
- Zod via
@hookform/resolvers(6.x, supports Zod 4)
Cons
- No Start integration; server validation and no-JS submission are ours to wire
- v8 is in beta — a major upgrade is coming soon
- A second validation adapter package; ADR-0005’s TanStack Form assumption would change
3. Conform
Section titled “3. Conform”Pros: built around progressive enhancement and FormData; Zod 4 support.
Cons: designed for Remix and Next.js server actions; no TanStack Start adapter and no shadcn/ui guide.
4. No library (native forms + React 19 useActionState)
Section titled “4. No library (native forms + React 19 useActionState)”Pros: zero dependencies. Cons: per-field errors, touched/dirty state, arrays, and async checks rebuilt in every product.
Progressive enhancement
Section titled “Progressive enhancement”- JS-required: forms submit through
useMutation; disabled until hydration. One code path, matches ADR-0012 and Better Auth’s client SDK - Progressive by default: every form posts
FormDatatoserverFn.urland works without JavaScript. Robust on slow networks, but every form needsFormDataparsing, server-rendered errors, and redirects — and auth forms don’t fit the client SDK
Decision
Section titled “Decision”TanStack Form with shared Zod schemas and shadcn/ui Field components is the company standard for forms. Forms require JavaScript by default; progressive enhancement is opt-in per form.
Concretely:
- Package:
@tanstack/react-form1.x. Add@tanstack/react-form-start(same version) only for no-JS forms - One form hook per app:
createFormHookContexts()+createFormHook()insrc/components/form/, binding field components built on shadcn/uiField(TextField,TextareaField,SelectField,CheckboxField,SwitchField, …) and form components (SubmitButton,FormError). Forms useuseAppFormandform.AppField; sub-forms usewithForm. Products extend this set — they don’t hand-wireform.Fieldper screen - Accessible errors, following shadcn/ui’s guide:
data-invalidonField,aria-invalidon the control,FieldErrorrendered whenisTouched && !isValid. Field components own this, so screens can’t forget it - One Zod schema per form, in a module both sides import (no server-only imports). The client uses it as the form validator (
validators: { onSubmit: schema }; addonBlurfor long forms). The server function validates with the same schema:createServerFn({ method: "POST" }).validator(schema). The server always validates; client validation is only for faster feedback - Submit through a mutation:
onSubmit: ({ value }) => mutation.mutateAsync(value), where the mutation calls thePOSTserver function viauseServerFnand invalidates on success (ADR-0012). The submit button showsisSubmittingand blocks double submits - Server errors map back onto fields. Business-rule failures (email taken, stock gone) return field-keyed errors from the server function and render through the same
FieldError; TanStack Form’s form-level validators can set field errors ({ fields: { email: "…" } }). Unexpected errors show one form-level message — never a stack trace or raw database error - Every write server function checks the session itself (auth middleware or an in-handler check) — a route’s
beforeLoadguards the page, not the endpoint - CSRF:
src/start.tsregisterscreateCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" })next to the rate limiter, because definingsrc/start.tsturns off Start’s automatic one. Always passorigin: APP_ORIGIN— behind the reverse proxy, the request URL isn’t a reliable source for the public origin (ADR-0017) - JS-required forms are safe before hydration:
<form method="post">so a native submit can never put values (passwords) in the URL, and the submit button is disabled until hydrated (useHydrated()from@tanstack/react-router) - Auth forms use the same form hook and field components;
onSubmitcalls Better Auth’s client (e.g.authClient.signIn.email) instead of a server function, then resets caches as in ADR-0012 - No-JS forms are opt-in — for public forms that must work without JavaScript (e.g. a contact form):
@tanstack/react-form-start’sformOptionsandcreateServerValidate,<form action={serverFn.url} method="post" encType="multipart/form-data">, andgetFormData+mergeFormto show server errors after the round trip
The deciding factors: the same team and release train as Router, Query, and Start; Zod schemas without an adapter; typed, pre-bound field components that make accessible errors the default; and one submit path through server functions and mutations. We accept the smaller ecosystem and the coming v2 upgrade.
Consequences
Section titled “Consequences”Easier:
- One schema validates the form, types its values, and guards the server function — client and server can’t drift
- Screens compose pre-built field components; labels, descriptions, and accessible error states come for free
- Every write goes through the same path: form → mutation →
POSTserver function → rate limiter → CSRF check → validation → handler → invalidation
Harder:
- The field-component set in
src/components/form/is ours to build and maintain; it is the first candidate for the shared shadcn/ui registry (ADR-0011) - Most community examples are React Hook Form; translate them rather than copy them
src/start.tsmust keep the CSRF middleware — removing it, or narrowing its filter, silently opens every server function to cross-site requests. Add a test that a cross-sitePOSTis rejected- Forms don’t work before hydration on slow connections; the disabled submit button makes that visible instead of silent
- TanStack Form v2 is in alpha; plan an upgrade window when it ships
Given up: React Hook Form’s ecosystem, and progressive enhancement as the default. Accepted — one release train and one submit path matter more for a company default; products with no-JS requirements opt in per form.

