Skip to content

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

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 Field components (Field, FieldLabel, FieldDescription, FieldError) ship guides for TanStack Form and React Hook Form
  • Writes: POST server functions called from useMutation, 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 checks Sec-Fetch-Site, Origin, and Referer. Start installs it automatically only when an app has no src/start.ts — and every app has one, for the rate limiter (ADR-0010)

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
  • createFormHook binds our own field components once; forms then use form.AppField with 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 without createFormHook
  • v2 is in alpha; a major upgrade will come

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

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.

  • 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 FormData to serverFn.url and works without JavaScript. Robust on slow networks, but every form needs FormData parsing, server-rendered errors, and redirects — and auth forms don’t fit the client SDK

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-form 1.x. Add @tanstack/react-form-start (same version) only for no-JS forms
  • One form hook per app: createFormHookContexts() + createFormHook() in src/components/form/, binding field components built on shadcn/ui Field (TextField, TextareaField, SelectField, CheckboxField, SwitchField, …) and form components (SubmitButton, FormError). Forms use useAppForm and form.AppField; sub-forms use withForm. Products extend this set — they don’t hand-wire form.Field per screen
  • Accessible errors, following shadcn/ui’s guide: data-invalid on Field, aria-invalid on the control, FieldError rendered when isTouched && !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 }; add onBlur for 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 the POST server function via useServerFn and invalidates on success (ADR-0012). The submit button shows isSubmitting and 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 beforeLoad guards the page, not the endpoint
  • CSRF: src/start.ts registers createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }) next to the rate limiter, because defining src/start.ts turns off Start’s automatic one. Always pass origin: 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; onSubmit calls 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’s formOptions and createServerValidate, <form action={serverFn.url} method="post" encType="multipart/form-data">, and getFormData + mergeForm to 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.

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 → POST server 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.ts must 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-site POST is 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.