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 realityExample
// 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.