Web Engineering ·

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.

Contract-First API Development with OpenAPI: Schema-Driven Validation, Type Generation, and Client SDK Automation in TypeScript

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.

DimensionCode-FirstContract-First
Spec accuracyDecays over timeEnforced by CI
Frontend unblockingBlocked until API shipsMock server from day one
Type safetyManual or generated after the factGenerated before implementation
Breaking change detectionDiscovered at runtimeCaught in PR review
Multi-team coordinationAd-hocSpec 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:

  1. Add the new field or endpoint to the spec alongside the old one
  2. Mark the old field as deprecated using the deprecated: true property in the schema
  3. Set a sunset date in the spec description
  4. Monitor usage of the deprecated field via request logging
  5. 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:

  1. Spec changes go through pull request review, the same as code changes
  2. CI lints the spec, generates types, and runs contract tests on every PR
  3. The generated types file is committed to source control (not edited by hand)
  4. The mock server runs in CI so frontend tests run against a spec-accurate mock rather than a manually maintained stub
  5. Deprecated fields are tracked against a sunset date calendar that CI checks on each run
  6. 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
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
Web Engineering ·

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
Web Engineering ·

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
Web Engineering ·

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.