Contract-First Without the Codegen Dance: OpenAPI, Typed Client, and Contract Tests From One Definition
One app.route({...}) projects into generateOpenAPI(app), createClient(app), and runContractTests(app) — plus pnpm gen for a Hey API typed fetch SDK your frontend can import. With pictures.
Hi, I'm Devlin. Ten years of fullstack work. I have, in my career, personally maintained a hand-written openapi.yamlin a monorepo, and I have personally been the reason it was three sprints out of date. I've also been the person who shipped a frontend that called POST /book when the backend had quietly renamed it to POST /books the week before. So when I tell you the codegen dance is a real problem, please understand: I am one of the dancers.
The launch post promised you that one app.route({...})call is the source of truth for validation, types, OpenAPI, the typed client, and contract tests. That post was the "tell". This post is the "show". We're going to define a single route, project it into all three artifacts on disk and in tests, then run pnpm gen and use the typed SDK from a separate Next.js frontend. No yaml editing, no version drift, no second source of truth.
The one route
Here is the entire input. Everything that follows in this post is derived from this file. If it changes, everything else changes with it. If it doesn't, nothing else does. That is what "single source of truth" actually has to mean — not "we have a wiki page about it".
One route, two declared responses (200 and 404), each with a real Zod schema. Hold that file in your head — we'll come back to it three times.
Three projections, one input
generateOpenAPI(app) — the spec is a function of the routes
The OpenAPI document is not a separate file you maintain. It is a pure function of the routes you registered. Call generateOpenAPI(app, ...), get a fully-formed RFC 3.1 document back, write it wherever you want it.
The proof is one jq away:
The operationId on the route became the operationId in the spec. The set of declared responses became the set of documented responses. There is no second list to update.
createClient(app) — the typed client lives in the same monorepo
createClient<A extends App>(app, opts) returns an object keyed by every operationIdyou defined, with full input/output type narrowing per status. The classic use for it is "in-process integration tests" — point its fetch at app.fetch and you get a real end-to-end test without a socket:
The two things I want you to notice in that snippet are also the two things I quietly celebrate every time I see them at work. First, the res.body inside the 200 branch is narrowed to the Book shape — not the union of every declared response, the actual 200 one. Second, the @ts-expect-error comment in the404 branch passes: trying to read title from a Problem is a compile error, by construction.
runContractTests(app) — the guardrails you forgot to write
runContractTests(app, opts) walks every registered route and checks the boring rules that turn into 3am bugs: every route has a unique operationId, every route declares at least one response, declared examplesvalidate against their declared schema, and safe methods don't carry request bodies unless you explicitly allow it.
This is the test I add first to every new project, before any feature tests. It catches the "oh, two routes accidentally share an operationIdbecause copy-paste" bug that ruins your generated SDK before it's even generated. Cheap to write, expensive to forget.
The codegen dance, but the dance is one command
All right — the three projections above never leave your repo. What about the otherconsumer of your API, the one written in a different repo, possibly by a different team, possibly in a different language than yours? That's where pnpm gen comes in. Two scripts, one parent script:
gen:openapi calls the dump script you already saw. gen:client hands that JSON to Hey API's @hey-api/openapi-ts via this tiny config:
Now run it:
That is the entire "dance". No swagger-codegen Java invocation. No --lang typescript-fetch flag you googled three years ago. No Docker container. No post-processing script. generated/client/ is now a real, typed, tree-shakeable fetch SDK that you can import from anywhere you can import TypeScript.
Using it from a separate Next.js frontend
Here is the part that closes the loop. The frontend lives in a different app (apps/web in a monorepo, or a totally separate repo with the client published to a registry — your call). It imports the generated SDK and calls it like any other module. Pay attention to the call shape — path for path params, { data, error, response } destructure for results:
That is a Next.js 16 server component, with shadcn-style classes, calling a typed SDK that was generated from a Zod schema on the other side of the monorepo. data.title is a string. data.publishedYear is a number | undefined. If the backend renames title to name, this file refuses to compile, and the frontend developer finds out before the PR even opens — not after the user complains.
The diff that doesn't exist
Let me show the thing I most want you to feel. Change one field in the route. Watch what moves on its own.
The diff in the route file is two lines. The diff in your openapi.yaml, your client types, your contract tests, your frontend imports, and your "types package" is zero lines, because those files don't exist as separate truths anymore. You commit the route change, you run pnpm gen, the SDK regenerates. That's it. That's the post.
The four-step checklist for new projects
If you're bootstrapping a contract-first stack today, this is the order I'd do it in, having now done it more times than I care to admit:
- Write the smallest route with real
requestandresponsesschemas. Don't hand-roll types anywhere. - Add the contract test (
runContractTests(app)) before any feature test. It costs nothing and catches the bugs that hurt the most. - Add the in-process client test (
createClient(app, { fetch: app.fetch })). You now have integration coverage without a server. - Wire
pnpm genand import the generated SDK in your frontend. Delete any hand-written API client. (This is the dopamine part.)
The honest part
Code generation has had a bad reputation in the JS world for a long time, and honestly it earned that reputation — most pipelines were brittle, slow, and produced types that looked like they were translated from another language by someone who didn't want to be there. The reason the workflow above works is not that we're cleverer than the previous attempts. It's that we're standing on the shoulders of three sturdy things at once: Standard Schema lets the route own validation andtypes, OpenAPI 3.1 is the lingua franca for handing that to the outside world, and Hey API takes that spec and produces a typed fetch SDK that doesn't look like a translation. We just connected them.
If you want to go deeper, the typed client docs, the OpenAPI docs, and the testing docs each cover one of these three projections in detail. Or run pnpm create daloy@latest, point pnpm gen at it, and watch the dance turn into a single key press.
Thanks for reading. Now go delete a hand-written API client. It will be the best part of your week.
— Devlin