The tree under the page editor is not a page. A page is one way of rendering it. Which means headers, footers, sidebars, templates, dialogs, forms and...
|
Jan 12, 2026Giant Context builds and hosts websites, and the console is the authenticated portal where customers manage theirs. Every form in that console validates against a schema no one wrote on the client.
Drift is the default condition of web forms. The server holds an opinion about what a valid organization name is. The form holds another. The opinions live in different files, usually in different languages, and every schema change is an invitation for them to disagree. The user is the one who finds the disagreement. They fill out a form the client blesses and meet a 400 the server sends anyway, or they fight an input rule the server stopped caring about a year ago. The client is working from a copy of what the server accepted the last time someone checked.
There is no drift in this console, and discipline has nothing to do with it. The client's opinion is generated from the server's, so there is only one opinion.
The form system was not built as a feature. It follows from a decision that predates every form in the product: the API is the source of truth, and everything downstream of it is materialized. The database is strongly typed and migrated in writing. The API routes declare their request and response schemas. From those declarations the machinery generates the typed client, the zod validation, the data hooks, the query cache keys, and the mocks and factories the tests run on.
One week into the repository:
2025-12-19 · 8e6f18335
feat(portal): add i18n, code generation, and schema-driven architecture
- Auto-generate routes from src/routes/*/view.tsx file structure- Auto-generate query keys from OpenAPI spec- Auto-generate React Query hooks and form hooks from OpenAPI specThe commit that generates the console's routes generates the form hooks three bullets later. Forms were never designed here. They came out of the architecture that was already there.
A route in the API declares its request schema: the field names, types, bounds and patterns. Fastify enforces that schema at the boundary, so a request that violates it never reaches a handler. The server's half of validation is part of the framework rather than something added on top.
The same declarations feed the API's self-description, published as an OpenAPI spec. A generator compiles that spec into the client's half: a typed API client and zod schemas, one per operation.
Declared once
Fastify enforces
The spec
zod + client
Validates live
Declared once
Fastify enforces
The spec
zod + client
Validates live
This is what comes out the far end, in the generated client:
The generated zod schema
export const createOrganization = z .object({ name: z.string().min(1).max(100), slug: z .string() .min(1) .max(50) .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/), description: z.string().optional(),No one wrote that zod. The route declared its shape and the generator did the rest. The slug regex in the form is the same regex the server enforces, not because anyone kept them aligned but because there is one regex. When the route's declaration changes, this file changes at the next generation pass and the form's behaviour changes with it. The rule lives in one place, so there is nowhere else for it to go stale.
Several separate things have to agree for a form to work: the validation schema, the client method that submits it, and the cache keys that get invalidated afterwards. They agree because every generator keys off the same string.
A route declares an operation id. That id becomes the name of the generated zod schema, the name of the client method, the name of the React Query hook, and the query key. Nothing matches them up afterwards. They are the same identifier, spelled once in the route file and copied outward by machine.
Which is why the three arguments to the factory line up. They are not three things that happen to correspond. They are four projections of one declaration.
Two rules keep it that way. Every generated file opens with a banner saying not to edit it, and the only correct response to a wrong generated file is to change the route and run the generator again. And the generator does not read the repository. It fetches the specification from the running API, which means the spec reflects what the process has actually loaded rather than what is sitting on disk. That is faster and it has a hazard in it: a route that is running but not committed is in the spec, and its shape bakes into everything generated from that snapshot.
What remains for a form to be is three references and a factory call. Which schema, which endpoints, which cache keys. A 320-line utility called createCoreForm ties them together. From its doc comment:
createCoreForm, from its doc comment
/** * Factory for CRUD resource forms. * Works for both top-level and nested resources - caller binds * parent params. * * Usage: * const orgForm = createCoreForm({ * schema: createOrganization, * create: (data) => api.createOrganization(data), * update: (data, id) => api.updateOrganization(data, { params: { id } }), * delete: (id) => api.deleteOrganization(undefined, { params: { id } }), * listKey: queryKeys.organizations, * itemKey: (id) => queryKeys.organization(id), * }); */Every argument in that call is generated. The schema, the client methods, the cache keys. The factory wires the schema into the form resolver, so fields validate as the user types. It wires the client methods into mutations, so submit is typed end to end. And on success it invalidates the resource's cache keys, so the queries refetch and every surface showing that data updates, the same reactive loop that handles any other write. A form submission is one more mutation through the same path everything else takes.
The old failure modes stop being possible. The server tightens the name limit to 80 characters, and the next generation pass tightens the form, with no ticket filed. The server adds a required field, and the generated types change, so every form touching that operation fails typecheck until it renders the field. An endpoint gets renamed, and the old client method stops existing, so the build says so before the commit lands. All three of those used to reach a user in production. Now they are compile errors.
The schema covers the contract, not the presentation. A confirm-password field, a cross-field rule, a warning before you leave unsaved changes, those are still the client's own opinions, written by hand, able to be wrong. The guarantee is narrower than it sounds: nothing the client hand-writes can misrepresent what the server will accept.
I would not tell you your stack needs this. The generation machinery exists here for reasons bigger than forms, and the same pipeline turned out a router two weeks ago. Standing all of it up only to keep form validation honest would cost far more than it did for me, because for me most of it was already there.
One month into this repository I have written no client-side validation at all. Every form validates against the server's actual rules, catches errors before the request leaves, and refreshes every surface that cares when the write lands. When a form breaks, it breaks at typecheck, in the editor, in front of me.
If your API already describes itself, forms are the cheapest place to start pulling validation out of the client's hands.
Remote, Pacific time, full-time or contract. Get in touch.