API Contracts Between Frontend and Backend
The frontend should never discover a breaking API change in production. Shared contracts make mismatches visible in CI, not at 3 AM.
Article focus
Zero runtime surprises
When contracts are shared and enforced
Key takeaways
- Shared types (Zod, tRPC, or a types package) turn API mismatches into compile errors.
- Contract testing catches breaking changes before they reach production.
- Even without a monorepo, you can share types via a published package or code generation.
Why do API mismatches keep happening?
Because the frontend and backend each have their own definition of the data shape, and nothing enforces they stay in sync.
The frontend expects user.profilePicture. The backend sends profile_picture. The page renders a broken image icon. No error, no warning, no stack trace. Just a missing pixel that nobody notices until a customer complains.
This is not a skill problem. It is a communication problem. The frontend team and the backend team (or the same developer wearing both hats) each maintain their own mental model of the data. Those models drift over time because nothing holds them together.
The fix is a single source of truth for the shape of data that crosses the network boundary. Define it once. Reference it from both sides. Let the type checker enforce the agreement. This is one of the five pillars in the Frontend Architecture framework.
Live Schema Validation Demo
Toggle between valid and invalid payloads, or edit the JSON directly. The validation runs in real time.
Edit the JSON or click presets. The validation runs in real time, just like a Zod schema.
Option 1: Zod schemas as the contract
Define schemas in a shared package. Frontend and backend both import them. Validation and types from one source.
Zod is my default choice for API contracts because it serves two purposes simultaneously: it validates data at runtime and infers TypeScript types at compile time. One definition, two guarantees.
The key is that the schema lives in a place both sides can import. In a monorepo, that is a shared package. In a multi-repo setup, it can be a published npm package or generated from an OpenAPI spec.
typescript
// shared/schemas/user.ts - the single source of truth
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
displayName: z.string().min(1).max(100),
avatarUrl: z.string().url().optional(),
role: z.enum(['admin', 'member', 'viewer']),
createdAt: z.string().datetime(),
});
export type User = z.infer<typeof UserSchema>;
// Backend: validate the response before sending
app.get('/api/users/:id', (req, res) => {
const user = await db.findUser(req.params.id);
res.json(UserSchema.parse(user)); // throws if shape is wrong
});
// Frontend: validate the response after receiving
const { data, error } = await client
.from('/api/users/123')
.get();
const user = UserSchema.parse(data); // catches drift immediatelyOption 2: tRPC - no contract, just functions
tRPC eliminates the API contract entirely by making backend procedures callable as typed functions from the frontend.
tRPC takes a different approach. Instead of defining a contract between two sides, it removes the boundary. Backend procedures become directly callable from the frontend with full type safety. No REST endpoints, no manual parsing, no duplicate type definitions.
tRPC shines in full-stack TypeScript projects (especially Next.js or Express) where the frontend and backend are in the same repository. It eliminates an entire category of bugs - the "I renamed a field on the backend but forgot to update the frontend" category.
The trade-off is that it tightly couples your frontend to your backend framework. If your backend is not TypeScript (Python, Go, Rust), tRPC is not an option. In that case, Zod schemas or OpenAPI generation are the better path.
typescript
// Backend: define the procedure
export const userRouter = t.router({
getById: t.procedure
.input(z.string().uuid())
.query(async ({ input }) => {
return db.findUser(input);
}),
});
// Frontend: call it like a function - fully typed
const user = await trpc.user.getById.query('123');
// user is typed as the return type of the backend procedureOption 3: OpenAPI + code generation
Define the API in an OpenAPI spec. Generate client code for the frontend. The spec is the contract.
When the backend is not TypeScript, OpenAPI is the lingua franca. Define your API in an OpenAPI 3.x spec. Use a tool like openapi-typescript or openapi-generator to generate typed frontend clients.
The generation step is critical. If you write the types by hand based on the OpenAPI spec, you have already introduced drift potential. Generate them automatically in CI and check the diff.
yaml
# openapi.yaml - the contract
paths:
/users/{id}:
get:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/User'
components:
schemas:
User:
type: object
properties:
id: { type: string, format: uuid }
displayName: { type: string }
avatarUrl: { type: string, format: uri }
role: { type: string, enum: [admin, member, viewer] }Contract testing - the safety net
Even with shared types, contract tests verify that the actual API response matches the expected schema in CI.
Shared types catch most mismatches, but they cannot catch everything. What if the backend changes the response shape but forgets to update the shared schema? What if a new field is added that breaks a strict validation?
Contract testing solves this. It is a small suite of tests that hit the real API (or a recorded response) and validate the response against the expected schema.
Run these in CI after deployment but before switching traffic. Or run them against a staging environment. The goal is to catch mismatches before they reach users.
typescript
// contract-tests/users.test.ts
import { UserSchema } from '@shared/schemas/user';
describe('GET /api/users/:id', () => {
it('returns a valid user shape', async () => {
const response = await fetch('/api/users/123');
const data = await response.json();
// This throws if the API response
// does not match the expected shape
expect(() => UserSchema.parse(data)).not.toThrow();
});
it('returns the correct role values', async () => {
const response = await fetch('/api/users/123');
const data = await response.json();
expect(data.role).toMatch(/^(admin|member|viewer)$/);
});
});A tiered approach for existing projects
Start with runtime validation on the frontend, add shared types for the most critical endpoints, then add contract tests in CI.
If you are working in a codebase with no contracts today, do not try to fix everything at once. Use a tiered approach:
Each tier adds protection without requiring a full rewrite. Start with the endpoints that change most often or cause the most damage when they break.
- Tier 1 - Add Zod validation on the frontend for API responses. At minimum, parse the response and log or surface errors. This protects users from silent failures.
- Tier 2 - Extract schemas into a shared location (even just a file that both sides copy from, though shared is better). Focus on the most frequently changing endpoints.
- Tier 3 - Add contract tests in CI for critical paths (auth, billing, data export). These are high-value, low-maintenance.
- Tier 4 - Move toward a monorepo or shared package so the types are always in sync. Generate clients from OpenAPI if the backend is not TypeScript.
Try It: Tiered Approach Explorer
Click each tier to see details, mark progress, and watch protection build up as you go.
Add Zod parsing on the frontend for API responses. Surface errors instead of silent failures.
- Parse API responses with Zod schemas
- Surface errors instead of silent failures
- Takes ~10 minutes per endpoint
const parsed = UserSchema.parse(data);
// throws if shape is wrong - loud failureCumulative Protection by Tier
Each tier adds measurable protection. Even Tier 1 alone catches most silent failures.
Need help implementing this?
I build these systems for a living - let's work on yours.
Frontend Architecture & System Design
Structure so teams can ship. Clear boundaries, state strategy, and contracts. New features land cleanly; refactors stay low-risk.
Full Stack Development
End-to-end apps: MERN, React+Node, or MEVN. Auth, role-based access, and real-time where it matters. Designed to scale without rewrites.
Frontend Development
Production UIs in React, Next.js, or Vue- third-party and payment integrations included. Built for real traffic and maintained in production.
Your turn
- >Did this help you ship something?
- >Which part clicked the most for you?
- >Applying this at work? Share your experience.
Recommended blogs
Continue reading

Improving Next.js Lighthouse Without Killing the Design
How I chase Lighthouse and Core Web Vitals on a real Next.js portfolio without turning the UI into a gray wireframe.
Photo by Pixabay on Pexels
Read article
Onboard to Any Git Repo Fast: 5 Commands Before I Read Code
Five git commands I run before reading any code: find churn hotspots, map ownership risk, spot bug clusters, read delivery cadence, and detect firefighting patterns in minutes.
Photo from Pexels
Read article
Discussion
Leave a comment
Thoughts, questions, corrections - all welcome.