Contract-firstShow, don't tell

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.

Devlin DuldulaoFullstack cloud engineer12 min read

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".

apps/api/src/routes/books.tsapps/api/src/app.ts
ts
// apps/api/src/routes/books.ts
import { z } from "zod";
import { app } from "../app";

const Book = z.object({
  id: z.string(),
  title: z.string(),
  author: z.string(),
  publishedYear: z.number().int().optional(),
});

const Problem = z.object({
  type: z.string().url(),
  title: z.string(),
  status: z.number().int(),
  detail: z.string().optional(),
});

app.route({
  method: "GET",
  path: "/books/:id",
  operationId: "getBookById",
  summary: "Fetch a book by id",
  tags: ["books"],
  request: {
    params: z.object({ id: z.string().min(1) }),
  },
  responses: {
    200: { description: "Found", body: Book },
    404: { description: "Not found", body: Problem },
  },
  handler: async ({ params }) => {
    if (params.id === "missing") {
      return {
        status: 404,
        body: {
          type: "https://example.com/errors/not-found",
          title: "Not Found",
          status: 404,
        },
      };
    }
    return {
      status: 200,
      body: { id: params.id, title: `Book ${params.id}`, author: "Unknown" },
    };
  },
});
● apps/api/src/routes/books.ts — saved

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

1

generateOpenAPI(app) — the spec is a function of the routes

app.route({...})generated/openapi.json

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.

apps/api/scripts/dump-openapi.ts
ts
// apps/api/scripts/dump-openapi.ts
import { writeFileSync } from "node:fs";
import { generateOpenAPI } from "@daloyjs/core/openapi";
import { app } from "../src/app";

const doc = generateOpenAPI(app, {
  info: { title: "Bookstore API", version: "1.0.0" },
  servers: [{ url: "http://localhost:3000" }],
});

writeFileSync("generated/openapi.json", JSON.stringify(doc, null, 2));
tsx scripts/dump-openapi.ts ✓ wrote 14 KB

The proof is one jq away:

bash
$ jq '.paths."/books/{id}".get | {operationId, responses: (.responses | keys)}' \
    generated/openapi.json
{
  "operationId": "getBookById",
  "responses": ["200", "404"]
}

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.

2

createClient(app) — the typed client lives in the same monorepo

app.route({...})ClientFor<App>

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:

apps/api/tests/books.in-process.test.tsapps/api/src/routes/books.ts
ts
// apps/api/tests/books.in-process.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { createClient } from "@daloyjs/core/client";
import { app } from "../src/app";

// In-process: route the client's fetch straight into the app. No socket,
// no port, no flaky CI. Same validation, same response shape as production.
const client = createClient(app, {
  baseUrl: "http://app.local",
  fetch: (req) => app.fetch(new Request(req)),
});

test("getBookById — 200 has a typed body", async () => {
  const res = await client.getBookById({ params: { id: "42" } });
  assert.equal(res.status, 200);
  if (res.status === 200) {
    // res.body is { id: string; title: string; author: string; publishedYear?: number }
    assert.equal(res.body.id, "42");
  }
});

test("getBookById — 404 has the Problem body", async () => {
  const res = await client.getBookById({ params: { id: "missing" } });
  assert.equal(res.status, 404);
  if (res.status === 404) {
    assert.equal(res.body.status, 404);
    // @ts-expect-error — title belongs to Book, not Problem
    res.body.title;
  }
});
✓ node --test — 2 passing

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.

3

runContractTests(app) — the guardrails you forgot to write

app.route({...}){ ok, checked, issues }

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.

apps/api/tests/contract.test.ts
ts
// apps/api/tests/contract.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { runContractTests } from "@daloyjs/core/contract";
import { app } from "../src/app";

test("every route is a good citizen", async () => {
  const report = await runContractTests(app, {
    requireOperationId: true,        // default
    allowBodyOnSafeMethods: false,   // default
  });

  if (!report.ok) {
    // Pretty failure: which route, which check, which message.
    for (const issue of report.issues) {
      console.error(
        `  ✗ ${issue.method} ${issue.path}: ${issue.message} (${issue.code})`,
      );
    }
  }

  assert.ok(report.ok, `${report.issues.length} contract issue(s) found`);
  console.log(`✓ ${report.checked} routes checked`);
});
✓ 12 routes checked — all clean

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:

apps/api/package.jsonopenapi-ts.config.ts
json
// apps/api/package.json
{
  "scripts": {
    "gen:openapi": "tsx scripts/dump-openapi.ts",
    "gen:client":  "openapi-ts",
    "gen":         "pnpm gen:openapi && pnpm gen:client"
  }
}
scripts.gen = gen:openapi && gen:client

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:

openapi-ts.config.ts
ts
// openapi-ts.config.ts
import { defineConfig } from "@hey-api/openapi-ts";

export default defineConfig({
  input: "./generated/openapi.json",
  output: { path: "./generated/client", format: "prettier" },
  plugins: ["@hey-api/client-fetch", "@hey-api/sdk", "@hey-api/typescript"],
});
hey-api · plugins: client-fetch, sdk, typescript

Now run it:

bash
$ pnpm gen
> apps/api gen
> pnpm gen:openapi && pnpm gen:client

 wrote generated/openapi.json
 wrote generated/client/index.ts
 wrote generated/client/sdk.gen.ts
 wrote generated/client/client.gen.ts
 wrote generated/client/types.gen.ts

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:

apps/web/app/books/[id]/page.tsxapps/web/api-client/index.ts
tsx
// apps/web/app/books/[id]/page.tsx
import { notFound } from "next/navigation";
import { client, getBookById } from "@/api-client";

client.setConfig({
  baseUrl: process.env.NEXT_PUBLIC_API_URL!,
});

export default async function BookPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  const { data, error, response } = await getBookById({ path: { id } });

  if (response.status === 404) notFound();
  if (error) throw error;

  return (
    <article>
      <h1>{data.title}</h1>
      <p className="text-muted-foreground">by {data.author}</p>
      {data.publishedYear ? <p>{data.publishedYear}</p> : null}
    </article>
  );
}
next 16 · server component · typed

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.

apps/api/src/routes/books.ts
diff
// One change in the route file…
- 200: { description: "Found", body: Book },
+ 200: { description: "Found", body: Book.extend({ rating: z.number().min(0).max(5) }) },

// …and immediately, without writing types or running codegen by hand:
//
//   - generated/openapi.json gains `rating` in the 200 schema
//   - createClient(app) narrows res.body to include rating
//   - the contract test still passes (operationId, response set unchanged)
//   - the frontend's getBookById() refuses to compile until you render it
//
// That last bullet is what I'm here for, honestly.
◐ src/routes/books.ts — modified

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:

  1. Write the smallest route with real request and responsesschemas. Don't hand-roll types anywhere.
  2. Add the contract test (runContractTests(app)) before any feature test. It costs nothing and catches the bugs that hurt the most.
  3. Add the in-process client test (createClient(app, { fetch: app.fetch })). You now have integration coverage without a server.
  4. Wire pnpm gen and 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