Giant 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. That sentence is the whole system.
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. Client-side validation is a claim the client has no authority to make.
There is no drift in this console, and not because of discipline. Discipline is a plan for losing slowly. The reason is structural. The client's opinion is generated from the server's, so there is exactly one opinion.
The form system is not a feature that got built. It is a consequence of 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. And from those declarations, the machinery generates the typed client, the zod validation, the data hooks, the query cache keys, the mocks and factories the tests run on. Typed database, generated schemas, generated forms, generated hooks, generated tests. It is all one idea.
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 same commit note that generates the console's routes generates the form hooks three bullets later. The forms were never designed as forms. They fell out of the architecture.
Client-side validation is a claim the client has no authority to make.
A validation rule runs one path, and it cannot fork.
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 native, not an add-on. The same declarations feed the API's self-description, published as an OpenAPI spec. And 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, necessarily rather than carefully. When the route's declaration changes, this file changes at the next generation pass, and the form's behavior changes with it. There is no second place where the rule lives, so there is no second place for it to rot.
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 entering the knot of schema, query, and cache, not a special event.
The server's rule, rendered before the request leaves: an inline validation error under the slug field.
const hasTestFile = (routeFilePath: string): boolean => { const dir = dirname(routeFilePath); const baseName = basename(routeFilePath, ".ts"); const testsDir = join(dir, "__tests__"); if (!existsSync(testsDir)) return false; const testFilePath = join(testsDir, `${baseName}.test.ts`); return existsSync(testFilePath);};The old failure modes do not survive contact with this machine. The server tightens the name limit to 80 characters, and the next generation pass tightens the form, 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. Each of these used to be a production surprise with a user in the blast radius. Here they are compile errors with a developer in front of them.
The schema covers the contract, not the presentation. A confirm-password field, a cross-field rule, a nicety like warning before you leave unsaved changes, those are still the client's own opinions, written by hand, able to be wrong. What the system guarantees is narrower and more valuable. Nothing the client hand-writes can misrepresent what the server will accept. The contract cannot be lied about. The garnish can.
The contract cannot be lied about. The garnish can.
I won't tell you your stack needs this. The generation machinery here exists for reasons bigger than forms (the same pipeline turned out a router two weeks ago), and if you'd be standing it up just to keep form validation honest, that's a different bill than the one I paid.
But the buy, one month into this repository, is that I have written zero lines of client-side validation and shipped none that lies. Every form validates against the server's actual opinion, catches its errors before the request leaves, and refreshes every surface that cares when the write lands. When a form breaks here, it breaks in the editor, at typecheck, in front of me.
If your API already describes itself, the forms are the cheapest place to start pulling validation out of the client's hands. That's what I did. Take the part that fits.
If you're interested in my work or Giant Context, contact me!