Skip to content
Back to Build Tutorials

API Contract

Define a versioned API contract with OpenAPI/zod that both client and server consume.

Why

The API contract is the agreement between frontend and backend — without a written, versioned contract, each side guesses and integration breaks at runtime. A contract defined in OpenAPI or zod serves as the single source of truth: the server validates against it, the client generates types from it, and the docs are auto-generated. Versioning the contract (v1, v2) lets you evolve the API without breaking existing clients — a breaking change is a new version, not a surprise. This is the difference between 'we deployed and the mobile app broke' and 'we deployed v2 and the mobile app on v1 kept working'.

When

When you have a client-server split. Takes 2-3 h for OpenAPI, 1h for zod. Skip for monoliths with no external API.

How

1. Define the contract as zod schemas (internal) or OpenAPI spec (external)
   - zod: const CreateUser = z.object({ name: z.string(), email: z.string().email() })
   - OpenAPI: write spec.yaml or generate from zod with zod-to-openapi

2. Validate every request and response against the contract
   - Server: parse input with schema, return 400 on failure
   - Client: parse response with schema, throw if the server sent unexpected data

3. Version the API in the URL (/api/v1/users) or header (Accept: application/vnd.api+json;version=1)
   - Never make breaking changes to v1 — add v2 and deprecate v1 with a Sunset header

4. Generate client types from the contract in CI
   - openapi-typescript spec.yaml -o src/api/types.ts
   - Fail the build if the generated types don't match the committed ones

5. Document with auto-generated docs (Scalar, Stoplight, or Redoc)
   - The spec IS the docs — no hand-written API docs that drift from reality

Example

// Contract: src/api/contracts.ts
import { z } from "zod";
export const CreateUser = z.object({ name: z.string().min(1), email: z.string().email() });
export type CreateUserInput = z.infer<typeof CreateUser>;
export const User = z.object({ id: z.string().uuid(), name: z.string(), email: z.string().email() });
export type UserResponse = z.infer<typeof User>;

// Server: validate input + output
export async function POST(req: Request) {
  const input = CreateUser.parse(await req.json());  // 400 on invalid
  const user = await createUser(input);
  return Response.json(User.parse(user));  // validate output too
}

// Client: types flow from the contract
async function createUser(input: CreateUserInput): Promise<UserResponse> {
  const res = await fetch("/api/v1/users", { method: "POST", body: JSON.stringify(input) });
  return User.parse(await res.json());  // throws if server is broken
}

Common Problems

  • ⚠Making breaking changes to v1 (removing a field, changing a type) and breaking all clients simultaneously.
  • ⚠Validating input but not output, so a DB bug returns malformed data that the client renders as undefined.
  • ⚠Hand-writing API docs that drift from the implementation, so consumers build against fiction.
  • ⚠Not versioning at all, so every change is a potential breaking change with no way to roll back clients.

Best Practices

  • ✓Use zod as the single contract definition and derive OpenAPI, client types, and docs from it — one source, many outputs.
  • ✓Add a Sunset header to deprecated endpoints with a removal date so clients have a migration deadline.
  • ✓Validate both input AND output in development — output validation catches DB/ORM bugs that input validation misses.
  • ✓Generate the client SDK in the monorepo and publish it as a package so consumers update via npm, not copy-paste.