API-First vs Code-First Development 2026
The Question Every API Team Faces
Before writing a single line of implementation code for a new API endpoint, you face a choice: define the API contract first (OpenAPI spec, Protobuf schema), then implement it — or write the implementation code and derive the API contract from it.
Both approaches work. Both have production deployments at scale. The right choice depends on your team structure, architecture, and what problem you're optimizing for.
In 2026, the landscape has shifted: code-first has become more capable (tRPC's TypeScript inference, FastAPI's Pydantic schema generation), while API-first tooling has become more developer-friendly (Stoplight's visual editor, Buf's Protobuf toolchain). The choice is no longer "developer experience vs contract rigor" — it's about which workflow fits your team.
TL;DR
API-first is the right choice when: multiple teams consume the API, the API is public or has external consumers, you need to design the API contract collaboratively (product, engineering, API consumers all review the spec), or you're using a language-agnostic schema (OpenAPI, Protobuf) that generates clients for multiple languages. Code-first is the right choice when: a single team builds and consumes the API, you're using a TypeScript full-stack (tRPC's type inference is the best developer experience available), or you're moving quickly and want to avoid the overhead of maintaining a separate schema file.
Key Takeaways
- API-first defines the contract before implementation — the OpenAPI spec (or Protobuf) is the source of truth, and implementation is validated against it.
- Code-first derives the contract from implementation — FastAPI generates OpenAPI from Python type hints; tRPC infers TypeScript types directly; Zod schemas generate both validation and documentation.
- tRPC is not API-first or code-first in the traditional sense — it eliminates the API contract layer entirely for TypeScript full-stack apps. The types are the contract.
- OpenAPI is the standard for REST API documentation — API-first with OpenAPI produces the spec that powers Swagger UI, Stoplight, Postman collections, and generated SDKs.
- Protobuf is the schema for RPC APIs — API-first with Protobuf generates type-safe clients in every language via protoc or Buf.
- Mock servers from specs are a key API-first benefit: Stoplight Prism and Microcks generate working mock servers from OpenAPI specs, letting frontend teams develop against a mock before the backend is implemented.
- Design-time validation is the strongest API-first argument: tools like 42Crunch, Spectral, and Stoplight catch breaking changes, security issues, and style violations in the spec before any implementation.
What Is API-First?
API-first development treats the API contract as the primary artifact of the design process — written, reviewed, and agreed upon before implementation begins.
The API-First Workflow
1. Define API contract (OpenAPI YAML / Protobuf)
2. Review contract with stakeholders (frontend, backend, consumers)
3. Generate mock server from spec — frontend develops against mock
4. Implement backend to spec
5. Validate implementation against spec (Prism, Schemathesis)
6. Generate client SDKs from spec
7. Deploy
OpenAPI Spec (API-First REST)
# openapi.yaml — written BEFORE any backend code
openapi: 3.1.0
info:
title: Users API
version: 1.0.0
description: API for managing user accounts
paths:
/users/{id}:
get:
operationId: getUser
summary: Get user by ID
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
security:
- BearerAuth: []
responses:
"200":
description: User found
content:
application/json:
schema:
$ref: "#/components/schemas/User"
example:
id: "550e8400-e29b-41d4-a716-446655440000"
email: "user@example.com"
name: "Jane Smith"
plan: "professional"
"404":
description: User not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/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":
description: Validation error
components:
schemas:
User:
type: object
required: [id, email, name, plan, createdAt]
properties:
id:
type: string
format: uuid
email:
type: string
format: email
name:
type: string
plan:
type: string
enum: [free, professional, enterprise]
createdAt:
type: string
format: date-time
CreateUserRequest:
type: object
required: [email, name]
properties:
email:
type: string
format: email
name:
type: string
minLength: 1
plan:
type: string
enum: [free, professional, enterprise]
default: free
Error:
type: object
required: [message, code]
properties:
message:
type: string
code:
type: string
securitySchemes:
BearerAuth:
type: http
scheme: bearer
Mock Server from Spec
# Stoplight Prism — generate mock server from OpenAPI spec
npx @stoplight/prism-cli mock openapi.yaml
# Mock server runs at http://localhost:4010
# Frontend team develops against mock before backend is ready
curl http://localhost:4010/users/550e8400-e29b-41d4-a716-446655440000
# Returns the example from the OpenAPI spec
SDK Generation from Spec
# Generate TypeScript SDK from OpenAPI spec
npx @hey-api/openapi-ts \
--input openapi.yaml \
--output src/client \
--client axios
# Generated SDK:
# src/client/services/UsersService.ts
# Fully typed based on OpenAPI schema
// Generated TypeScript client — fully typed from OpenAPI
import { UsersService } from "./client/services/UsersService";
// TypeScript knows:
// - getUser returns User | null
// - User has id, email, name, plan, createdAt
// - plan is 'free' | 'professional' | 'enterprise'
const user = await UsersService.getUser({ id: "550e8400-..." });
console.log(user.plan); // TypeScript-safe
Breaking Change Detection
# oasdiff — detect breaking changes between spec versions
npx oasdiff breaking openapi-v1.yaml openapi-v2.yaml
# Output:
# [ERROR] GET /users/{id} response 200 schema changed: 'name' field removed
# [WARNING] POST /users request body: new required field 'company' added
# → Breaking change: existing clients will fail
What Is Code-First?
Code-first development writes the implementation code first and derives the API contract from it.
Code-First with FastAPI (Python)
# FastAPI generates OpenAPI automatically from Python type hints
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr
from typing import Optional
from enum import Enum
import uuid
app = FastAPI(
title="Users API",
version="1.0.0",
description="API for managing user accounts",
)
class Plan(str, Enum):
free = "free"
professional = "professional"
enterprise = "enterprise"
class User(BaseModel):
id: str
email: EmailStr
name: str
plan: Plan
created_at: str
class CreateUserRequest(BaseModel):
email: EmailStr
name: str
plan: Optional[Plan] = Plan.free
@app.get("/users/{user_id}", response_model=User, tags=["users"])
async def get_user(user_id: str):
"""Get a user by their ID."""
user = await db.users.find_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@app.post("/users", response_model=User, status_code=201, tags=["users"])
async def create_user(data: CreateUserRequest):
"""Create a new user account."""
user = await db.users.create(
id=str(uuid.uuid4()),
email=data.email,
name=data.name,
plan=data.plan,
)
return user
# OpenAPI spec auto-generated at /openapi.json
# Swagger UI at /docs
# ReDoc at /redoc
Code-First with tRPC (TypeScript)
// tRPC — no OpenAPI, no schema file, just TypeScript
// The TypeScript types ARE the contract
import { router, publicProcedure } from "./trpc";
import { z } from "zod";
export const usersRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const user = await db.users.findUnique({ where: { id: input.id } });
if (!user) throw new TRPCError({ code: "NOT_FOUND" });
return user; // Return type inferred from Prisma schema
}),
createUser: publicProcedure
.input(z.object({
email: z.string().email(),
name: z.string().min(1),
plan: z.enum(["free", "professional", "enterprise"]).default("free"),
}))
.mutation(async ({ input }) => {
return db.users.create({ data: input });
}),
});
// No OpenAPI spec generated
// TypeScript client has full type inference from above:
const user = await trpc.users.getUser.query({ id: "..." });
user.plan; // TypeScript knows: "free" | "professional" | "enterprise"
Code-First with Zod + Express
// Zod schemas as the source of truth
import { z } from "zod";
import { generateOpenApi } from "@ts-rest/open-api";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
plan: z.enum(["free", "professional", "enterprise"]),
createdAt: z.string().datetime(),
});
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
plan: z.enum(["free", "professional", "enterprise"]).default("free"),
});
// Contract defined from Zod schemas — ts-rest generates both Express routes and OpenAPI
const contract = initContract();
export const usersContract = contract.router({
getUser: {
method: "GET",
path: "/users/:id",
responses: {
200: UserSchema,
404: z.object({ message: z.string() }),
},
pathParams: z.object({ id: z.string() }),
},
createUser: {
method: "POST",
path: "/users",
body: CreateUserSchema,
responses: {
201: UserSchema,
},
},
});
// Generate OpenAPI from the contract
const openApiDocument = generateOpenApi(usersContract, {
info: { title: "Users API", version: "1.0.0" },
});
When to Choose API-First
✓ Multiple teams or companies consume your API
✓ External (public) API where the contract is a product
✓ Need to design collaboratively before implementation
✓ Frontend team needs to develop before backend is ready (mock server)
✓ Generating clients in multiple languages (TypeScript, Python, Go SDKs)
✓ Need formal breaking change detection between versions
✓ Regulated industries where API contracts require sign-off
✓ Microservices where teams need stable interface contracts
When to Choose Code-First
✓ Single team builds and consumes the API (monorepo, full-stack TypeScript)
✓ Moving quickly and want to avoid schema maintenance overhead
✓ TypeScript full-stack — tRPC's type inference is superior DX
✓ FastAPI/Django REST Framework already generates good OpenAPI output
✓ Prototyping where the API contract will change frequently
✓ Internal tools consumed by a small, known set of clients
The Hybrid Approach
Many mature API teams use both:
1. Code-first for internal/internal services (tRPC, FastAPI auto-generation)
2. API-first for external/public API surfaces (written OpenAPI spec with review process)
3. Treat auto-generated specs as a starting point, not the source of truth
Example:
- Internal: Product team ← uses tRPC (code-first, zero contract overhead)
- External: Partner API → writes OpenAPI spec first, implements against it
- Customer-facing SDK: Generated from the external OpenAPI spec
Protobuf: Schema-First for RPC
For gRPC and Connect-RPC, the .proto file is always the source of truth — schema-first by design:
// users.proto — this is the API contract
// protoc or Buf generates clients in any language
syntax = "proto3";
package users.v1;
service UserService {
rpc GetUser (GetUserRequest) returns (GetUserResponse);
rpc CreateUser (CreateUserRequest) returns (CreateUserResponse);
}
message GetUserRequest {
string id = 1;
}
message GetUserResponse {
string id = 1;
string email = 2;
string name = 3;
UserPlan plan = 4;
}
enum UserPlan {
FREE = 0;
PROFESSIONAL = 1;
ENTERPRISE = 2;
}
# Buf — generate from Protobuf schema
buf generate
# Generates: TypeScript, Go, Python, Java clients from the same .proto
Decision Framework
| Factor | API-First | Code-First |
|---|---|---|
| Multiple API consumers | Best | Risky |
| External/public API | Required | Possible |
| TypeScript monorepo | Overkill | Best (tRPC) |
| Python backend | Either (FastAPI auto-gen) | Good |
| Cross-language clients | Required | Manual |
| Mock servers needed | Yes | Manual setup |
| Breaking change detection | Native | Manual |
| Design collaboration | Native | Requires extract |
| Speed of iteration | Slower | Faster |
| Schema drift risk | Low | High |
Verdict
API-first is the right architectural choice for APIs that are products — public APIs, partner APIs, multi-team internal APIs where the contract is the interface between teams. The design review process, mock servers, and generated clients from a formal spec create a discipline that prevents interface drift.
Code-first is the right choice for internal development velocity — particularly TypeScript full-stack teams using tRPC, where type inference from server to client eliminates an entire class of API bugs without any schema maintenance overhead.
The real answer: use both strategically. Code-first for internal service-to-service communication within a team; API-first for contracts that cross team or organizational boundaries.
Compare API design tools, OpenAPI generators, and documentation platforms at APIScout — find the right API design workflow for your team.