Web Development · API Design
OpenAPI-First API Development: Write the Contract Before the Code
Code-first API development produces documentation as an afterthought. OpenAPI-first flips that: you write the spec, generate the server stubs and client SDKs, and enforce the contract at every layer. Here's how it works in practice.
Anurag Verma
6 min read
Sponsored
Most teams write APIs the same way: build the endpoints, wire up the handlers, then generate docs from the running server. OpenAPI comes last, as a description of what was already built. The problem with this order is that the spec becomes a byproduct: accurate when it’s first generated, gradually wrong as the API evolves, rarely updated after the initial release.
OpenAPI-first reverses that. You write the spec first. The spec is the source of truth. Code is generated from the spec, validated against the spec, and clients are distributed as generated SDKs from the spec. Documentation is always correct because it’s the input, not the output.
This isn’t a new idea, but the tooling has matured enough in 2026 that it’s practical for projects of any size.
What You Get From a Good Spec
An OpenAPI 3.1 spec is a YAML or JSON document that describes every endpoint: its path, method, parameters, request body, possible responses, and the schemas for each. When it’s your source of truth, several things follow automatically:
Server-side validation: Requests that don’t match the spec can be rejected before they reach your handler. No manual if (!req.body.email) guards.
Generated client SDKs: Front-end teams get a typed client that matches the API exactly. No hunting through documentation to find the right parameter name.
Contract testing: You can run a test suite that verifies the live server matches the spec. Catch regressions before they reach production.
Consistent error responses: Define your error schemas once; every endpoint uses them.
The Spec
OpenAPI 3.1 adopted JSON Schema fully, which means you can use $ref to share schemas across your spec and use the full JSON Schema vocabulary.
A minimal spec for a user management API:
# openapi.yaml
openapi: 3.1.0
info:
title: User API
version: 1.0.0
paths:
/users:
post:
operationId: createUser
summary: Create a new user
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
responses:
'201':
description: User created
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
$ref: '#/components/responses/ValidationError'
'409':
$ref: '#/components/responses/ConflictError'
/users/{id}:
get:
operationId: getUser
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
$ref: '#/components/responses/NotFoundError'
components:
schemas:
CreateUserRequest:
type: object
required: [email, name]
properties:
email:
type: string
format: email
name:
type: string
minLength: 1
maxLength: 100
User:
type: object
required: [id, email, name, createdAt]
properties:
id:
type: string
format: uuid
email:
type: string
format: email
name:
type: string
createdAt:
type: string
format: date-time
responses:
ValidationError:
description: Request validation failed
content:
application/json:
schema:
type: object
required: [error, details]
properties:
error:
type: string
details:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string
Write this spec before a line of server code exists. Review it with the team (or client). Once everyone agrees on the contract, generate.
Generating Server Types in TypeScript
openapi-typescript generates TypeScript types from a spec with zero runtime overhead:
npm install -D openapi-typescript
npx openapi-typescript openapi.yaml -o src/generated/api.d.ts
The output is a types-only file. You get the schema types and response types for every endpoint:
import type { components, paths } from './generated/api.d.ts';
type User = components['schemas']['User'];
type CreateUserRequest = components['schemas']['CreateUserRequest'];
// The path types tell you exactly what parameters and responses look like
type GetUserResponse = paths['/users/{id}']['get']['responses']['200']['content']['application/json'];
Run this generation step in CI. If the spec changes and the types change, TypeScript will tell you everywhere in the codebase that’s now broken.
Runtime Validation with express-openapi-validator
Types catch errors at compile time. Request validation at runtime catches what clients send that doesn’t match your spec.
npm install express-openapi-validator
import express from 'express';
import OpenApiValidator from 'express-openapi-validator';
const app = express();
app.use(express.json());
app.use(
OpenApiValidator.middleware({
apiSpec: './openapi.yaml',
validateRequests: true,
validateResponses: true, // catches response bugs during development
})
);
// Your handlers — request bodies are already validated before reaching here
app.post('/users', async (req, res) => {
const body: CreateUserRequest = req.body; // guaranteed to match the schema
const user = await userService.create(body);
res.status(201).json(user);
});
// Error handler for validation failures
app.use((err: any, req: any, res: any, next: any) => {
if (err.status === 400) {
res.status(400).json({
error: 'Validation failed',
details: err.errors,
});
} else {
next(err);
}
});
With validateResponses: true in development, you’ll get errors when a handler returns a response that doesn’t match the spec. Useful for catching bugs before they reach clients.
Generating Client SDKs
openapi-fetch (from the openapi-ts project) gives you a type-safe fetch client generated directly from your spec:
npm install openapi-fetch
import createClient from 'openapi-fetch';
import type { paths } from './generated/api.d.ts';
const client = createClient<paths>({ baseUrl: 'https://api.yourapp.com' });
// Fully typed — TypeScript knows the params, body, and response type
const { data, error } = await client.POST('/users', {
body: {
email: 'user@example.com',
name: 'Test User',
},
});
if (data) {
console.log(data.id); // string — TypeScript knows this
}
If the spec changes (say you add a required field to CreateUserRequest), the generated types update and every client call that’s missing the field becomes a TypeScript error. The contract is enforced across the codebase automatically.
TypeSpec: Generating OpenAPI From Code
For teams that find writing YAML by hand tedious, TypeSpec (from Microsoft) is the reverse approach: write the API description in a TypeScript-like DSL, generate the OpenAPI spec from it.
npm install -g @typespec/compiler
tsp init
// main.tsp
import "@typespec/http";
using TypeSpec.Http;
@service({ title: "User API" })
namespace UserAPI;
model User {
id: string;
email: string;
name: string;
createdAt: utcDateTime;
}
model CreateUserRequest {
email: string;
name: string;
}
@route("/users")
interface Users {
@post
create(@body body: CreateUserRequest): User | ValidationError;
@get
@route("{id}")
get(@path id: string): User | NotFoundError;
}
tsp compile . --emit @typespec/openapi3
This generates openapi.yaml from the TypeSpec. TypeSpec handles versioning, pagination, and common patterns better than hand-written YAML for large APIs.
What This Changes in Practice
The discipline shift is real: you have to agree on the API contract before writing code. That’s a conversation that’s often deferred when teams code-first, which is part of why API design decisions get made late: in code review, or when a client tries to integrate and finds the shape unexpected.
OpenAPI-first makes that conversation happen at the right time. The spec is a document everyone can read, not just developers. Product managers and clients can review the API design before any code exists. Changes to the contract are explicit (they require spec changes) rather than accidental (someone refactors a handler and the response shape quietly changes).
For agencies shipping APIs that clients will integrate against, this matters a lot. A spec-first contract is also a scope boundary: deviations from the agreed spec are change requests, not bugs.
The toolchain (openapi-typescript for types, express-openapi-validator for runtime validation, openapi-fetch for clients) has zero lock-in. The OpenAPI spec is a standard. Switch frameworks, switch languages, switch tools: the spec stays.
Sponsored
More from this category
More from Web Development
CSS Anchor Positioning: Tooltips and Popovers Without JavaScript
gRPC in 2026: When to Use It Instead of REST or GraphQL
k6 Load Testing: Performance Testing Your APIs Before Users Find the Problems
Sponsored
The dispatch
Working notes from
the studio.
A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored