Contract-First API Development with OpenAPI: Schema-Driven Validation, Type Generation, and Client SDK Automation in TypeScript
Contract-first API development means writing the OpenAPI spec before writing a single route handler. This guide covers the full workflow: type generation with openapi-typescript, request/response validation in Hono and Next.js, mock servers for frontend teams, client SDK generation, contract testing in CI, and strategies for evolving contracts without breaking consumers.
Most teams write code first and document it second, which means the documentation is already wrong before anyone reads it. The spec is a post-hoc summary of what the implementation does, not a definition of what the API is supposed to do. When a frontend developer hits an endpoint that returns a shape different from what the Swagger page shows, the usual answer is “the docs are out of date.” That is a tooling problem disguised as a process problem.
Contract-first flips the order: define the OpenAPI specification first, then generate types, validation, mock servers, and client SDKs from it. The spec becomes the source of truth, and every artifact that consumers depend on flows from that single definition.
This article walks through the complete workflow, from spec to production, with TypeScript throughout.
Contract-First vs Code-First
The practical difference is not about documentation philosophy. It is about where drift is allowed to occur.
| Dimension | Code-First | Contract-First |
|---|---|---|
| Spec accuracy | Decays over time | Enforced by CI |
| Frontend unblocking | Blocked until API ships | Mock server from day one |
| Type safety | Manual or generated after the fact | Generated before implementation |
| Breaking change detection | Discovered at runtime | Caught in PR review |
| Multi-team coordination | Ad-hoc | Spec changes require explicit review |
Code-first is faster to start. If you are building a prototype or a single-team service that no one outside your team consumes, the overhead of contract-first is not justified. Once you have multiple consumers, separate frontend and backend teams, or external API users, contract-first pays back the upfront investment within the first breaking-change incident it prevents.
Writing the OpenAPI Spec
Start with a minimal spec in YAML. Keep it in a spec/ directory at the repository root. For a monorepo, the spec lives next to the service it describes.
# spec/openapi.yaml
openapi: "3.1.0"
info:
title: Payments API
version: "1.0.0"
paths:
/payments:
post:
operationId: createPayment
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreatePaymentRequest"
responses:
"201":
description: Payment created
content:
application/json:
schema:
$ref: "#/components/schemas/Payment"
"422":
description: Validation error
content:
application/json:
schema:
$ref: "#/components/schemas/ValidationError"
/payments/{id}:
get:
operationId: getPayment
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/Payment"
"404":
description: Not found
components:
schemas:
CreatePaymentRequest:
type: object
required: [amount, currency, sourceId]
properties:
amount:
type: integer
description: Amount in minor units (cents)
minimum: 1
currency:
type: string
enum: [USD, EUR, GBP]
sourceId:
type: string
format: uuid
idempotencyKey:
type: string
Payment:
type: object
required: [id, amount, currency, status, createdAt]
properties:
id:
type: string
format: uuid
amount:
type: integer
currency:
type: string
status:
type: string
enum: [pending, succeeded, failed]
createdAt:
type: string
format: date-time
ValidationError:
type: object
required: [errors]
properties:
errors:
type: array
items:
type: object
required: [field, message]
properties:
field:
type: string
message:
type: string
Two things to get right from the start. Use operationId on every operation: downstream code generators use this as the function name. Be precise about required fields: OpenAPI’s required array is easy to forget, and a missing field there means generated types will mark things optional that should not be.
Generating TypeScript Types with openapi-typescript
openapi-typescript converts your spec into a TypeScript types file with no runtime dependency. Run it as part of your build.
npm install --save-dev openapi-typescript
npx openapi-typescript spec/openapi.yaml -o src/generated/api.d.ts
Add this to package.json:
{
"scripts": {
"generate:types": "openapi-typescript spec/openapi.yaml -o src/generated/api.d.ts",
"prebuild": "npm run generate:types"
}
}
The generated file exports a paths object and a components object. You pull types from them using the components["schemas"] and operations helpers:
import type { components, operations } from "./generated/api.d.ts";
type Payment = components["schemas"]["Payment"];
type CreatePaymentRequest = components["schemas"]["CreatePaymentRequest"];
// Extract request body type for a specific operation
type CreatePaymentBody =
operations["createPayment"]["requestBody"]["content"]["application/json"];
These types are purely structural. They do not carry any runtime validation. The next step adds that.
Request and Response Validation in Hono
With types generated, you need runtime validation that enforces the same constraints. The cleanest approach is a Zod schema that mirrors the OpenAPI schema, with a linting step that keeps them in sync.
For Hono, use @hono/zod-validator to validate incoming requests before they reach your handler:
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import type { components } from "../generated/api.d.ts";
// Zod schema mirrors the OpenAPI schema for CreatePaymentRequest
const createPaymentSchema = z.object({
amount: z.number().int().min(1),
currency: z.enum(["USD", "EUR", "GBP"]),
sourceId: z.string().uuid(),
idempotencyKey: z.string().optional(),
});
// Type assertion that the Zod schema output matches the generated type
type _Check = z.infer<typeof createPaymentSchema> extends components["schemas"]["CreatePaymentRequest"]
? true
: never;
const app = new Hono();
app.post(
"/payments",
zValidator("json", createPaymentSchema, (result, c) => {
if (!result.success) {
return c.json(
{
errors: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
},
422
);
}
}),
async (c) => {
const body = c.req.valid("json"); // fully typed, no cast needed
const payment = await createPayment(body);
return c.json(payment, 201);
}
);
The _Check type assertion is a static compile-time guard. If you change the OpenAPI spec and regenerate types, but forget to update the Zod schema, the TypeScript compiler will catch the mismatch before it reaches CI.
For response validation, add it in development mode to verify your handler is actually returning what the spec promises:
import { components } from "../generated/api.d.ts";
import { z } from "zod";
const paymentResponseSchema = z.object({
id: z.string().uuid(),
amount: z.number().int(),
currency: z.string(),
status: z.enum(["pending", "succeeded", "failed"]),
createdAt: z.string().datetime(),
});
// Wrap response in dev only
async function respondWithPayment(c: Context, payment: unknown) {
if (process.env.NODE_ENV !== "production") {
const result = paymentResponseSchema.safeParse(payment);
if (!result.success) {
console.error("Response shape mismatch:", result.error.format());
}
}
return c.json(payment as components["schemas"]["Payment"], 201);
}
This finds handler bugs during local development and in test environments without adding overhead in production.
Validation in Next.js Route Handlers
In a Next.js app with the App Router, the same pattern works in route handlers:
// app/api/payments/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const createPaymentSchema = z.object({
amount: z.number().int().min(1),
currency: z.enum(["USD", "EUR", "GBP"]),
sourceId: z.string().uuid(),
idempotencyKey: z.string().optional(),
});
export async function POST(req: NextRequest) {
const body = await req.json();
const result = createPaymentSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{
errors: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
},
{ status: 422 }
);
}
const payment = await createPayment(result.data);
return NextResponse.json(payment, { status: 201 });
}
For teams with many routes, a middleware wrapper reduces repetition:
import { ZodSchema, ZodError } from "zod";
import { NextRequest, NextResponse } from "next/server";
function withBodyValidation<T>(
schema: ZodSchema<T>,
handler: (req: NextRequest, body: T) => Promise<NextResponse>
) {
return async (req: NextRequest): Promise<NextResponse> => {
let raw: unknown;
try {
raw = await req.json();
} catch {
return NextResponse.json({ errors: [{ field: "body", message: "Invalid JSON" }] }, { status: 400 });
}
const result = schema.safeParse(raw);
if (!result.success) {
return NextResponse.json(
{
errors: result.error.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
})),
},
{ status: 422 }
);
}
return handler(req, result.data);
};
}
// Usage
export const POST = withBodyValidation(createPaymentSchema, async (req, body) => {
const payment = await createPayment(body);
return NextResponse.json(payment, { status: 201 });
});
Mock Servers for Frontend Development
The most immediate productivity gain from contract-first is that frontend teams do not have to wait for backend implementation. A mock server running against the spec lets frontend development proceed in parallel.
@stoplight/prism-cli reads an OpenAPI spec and serves a mock that returns example responses derived from the schema:
npm install --save-dev @stoplight/prism-cli
# Start the mock server
npx prism mock spec/openapi.yaml --port 4010
For the mock to return useful data rather than empty objects, add example or examples to your schemas:
Payment:
type: object
required: [id, amount, currency, status, createdAt]
properties:
id:
type: string
format: uuid
example: "550e8400-e29b-41d4-a716-446655440000"
amount:
type: integer
example: 4999
currency:
type: string
example: "USD"
status:
type: string
enum: [pending, succeeded, failed]
example: "succeeded"
createdAt:
type: string
format: date-time
example: "2026-04-19T12:00:00Z"
Add a script to package.json and run it in your frontend’s dev environment:
{
"scripts": {
"mock:api": "prism mock ../api/spec/openapi.yaml --port 4010"
}
}
Frontend developers point their API client at localhost:4010 during development. When the real backend ships, they update the base URL. The shape is guaranteed to match because both sides derive from the same spec.
Generating Type-Safe Client SDKs
openapi-fetch (from the same team as openapi-typescript) generates a type-safe fetch client directly from the generated types, with no code generation step at the client layer:
npm install openapi-fetch
import createClient from "openapi-fetch";
import type { paths } from "./generated/api.d.ts";
const client = createClient<paths>({
baseUrl: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4010",
});
// Fully typed: body, query params, path params, and response
async function getPayment(id: string) {
const { data, error } = await client.GET("/payments/{id}", {
params: { path: { id } },
});
if (error) {
// error is typed to the 404 response schema
throw new Error("Payment not found");
}
return data; // typed as components["schemas"]["Payment"]
}
async function createPayment(body: CreatePaymentRequest) {
const { data, error } = await client.POST("/payments", {
body,
});
if (error) {
// error is typed to the 422 response schema
console.error(error.errors);
throw new Error("Payment creation failed");
}
return data;
}
The TypeScript compiler enforces the correct path parameters, body shape, and response handling. If you rename a field in the spec and regenerate types, every call site that uses the old field name will fail to compile.
For teams that need a fully generated SDK with a class-based interface, openapi-generator-cli with the typescript-fetch or typescript-axios generator is an alternative. It produces more code but gives you a conventional SDK shape that non-TypeScript consumers can also use.
Contract Testing in CI
Type generation catches structural mismatches at compile time. Contract testing catches behavioral mismatches at runtime. The distinction matters: a handler might return a field with the right name but the wrong value range, or omit a required field only when certain conditions hold.
openapi-backend is a good fit for contract testing because it validates requests and responses against the spec at the handler level:
import OpenAPIBackend from "openapi-backend";
import type { Context } from "openapi-backend";
const api = new OpenAPIBackend({
definition: "spec/openapi.yaml",
validate: true,
});
await api.init();
// In your test suite
describe("POST /payments", () => {
it("returns a payment matching the spec schema", async () => {
const response = await fetch("http://localhost:3000/payments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amount: 4999,
currency: "USD",
sourceId: "550e8400-e29b-41d4-a716-446655440000",
}),
});
const body = await response.json();
// Validate response against spec
const validationResult = api.validateResponse(body, api.router.getOperation("createPayment")!, response.status);
expect(validationResult.errors).toBeNull();
expect(response.status).toBe(201);
});
});
For CI, also run a spec linter to catch structural problems in the spec itself before they propagate:
# spectral is an OpenAPI linter
npm install --save-dev @stoplight/spectral-cli
# .spectral.yaml
extends: ["spectral:oas"]
rules:
operation-operationId: error
operation-description: warn
oas3-valid-media-example: error
Add both to your CI pipeline:
# .github/workflows/api-contract.yml
name: API Contract
on: [pull_request]
jobs:
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- name: Lint OpenAPI spec
run: npx spectral lint spec/openapi.yaml
- name: Generate types
run: npm run generate:types
- name: Type check
run: npx tsc --noEmit
- name: Run contract tests
run: npm test -- --testPathPattern=contract
This pipeline ensures that a PR cannot merge with a broken spec, a type mismatch between the spec and implementation, or a handler that returns responses violating the schema.
Evolving Contracts Without Breaking Consumers
Schema evolution is where most teams get into trouble. The rule is simple but requires discipline: additive changes are safe, everything else requires a versioning decision.
Non-breaking changes:
- Adding an optional field to a request schema
- Adding a field to a response schema (consumers should ignore unknown fields)
- Adding a new endpoint
- Adding a new enum value to a response field (this can break strict enum parsing on the client)
Breaking changes:
- Removing a field from a request or response
- Changing a field’s type
- Making an optional request field required
- Removing an enum value that existing clients may be sending
For breaking changes, the workflow is:
- Add the new field or endpoint to the spec alongside the old one
- Mark the old field as deprecated using the
deprecated: trueproperty in the schema - Set a sunset date in the spec description
- Monitor usage of the deprecated field via request logging
- Remove the deprecated field after the sunset date
# Deprecation example in OpenAPI spec
CreatePaymentRequest:
type: object
properties:
amount:
type: integer
minimum: 1
amountCents:
type: integer
deprecated: true
description: "Deprecated: use `amount` instead. Will be removed 2026-07-01."
For major version bumps where too many things change at once, maintain a separate spec file per major version (spec/v1/openapi.yaml, spec/v2/openapi.yaml) and generate separate type files from each. This is more maintenance overhead but avoids the alternative: a single sprawling spec that tries to describe two incompatible API surfaces simultaneously.
Tradeoffs to Acknowledge
Contract-first is not universally better. Writing a complete OpenAPI spec before implementation requires thinking through the API design in advance. Teams still discovering the shape of their domain through implementation will find the spec a constraint rather than a guide.
The toolchain has rough edges. openapi-typescript generates types but not Zod schemas, so you maintain both in parallel. Response validation in production is typically too expensive to run on every request, so the guarantee is weaker than compile-time type safety. The mock server is useful but is not a substitute for integration testing against the real implementation.
The Zod-OpenAPI gap is addressable with zod-to-openapi, which lets you define Zod schemas and derive the OpenAPI spec from them rather than maintaining both separately. This works well for simple schemas but breaks down when you need OpenAPI features that Zod does not model: discriminator, allOf with complex inheritance, or detailed example values.
The Production Workflow
A mature contract-first setup looks like this:
- Spec changes go through pull request review, the same as code changes
- CI lints the spec, generates types, and runs contract tests on every PR
- The generated types file is committed to source control (not edited by hand)
- The mock server runs in CI so frontend tests run against a spec-accurate mock rather than a manually maintained stub
- Deprecated fields are tracked against a sunset date calendar that CI checks on each run
- The spec is published to an internal developer portal so consumers can discover APIs without asking the owning team
The investment pays back when the CI pipeline catches a breaking change in a pull request rather than a 3am alert from a consumer whose integration stopped working.
API contracts exist whether you make them explicit or not. The question is whether the spec you publish matches the API you run.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.