Hero image for Type-Safe Frontend + Backend Contracts Using Shared Zod Schemas

Type-Safe Frontend + Backend Contracts Using Shared Zod Schemas

• 3 min read
TypeScript Zod API Design Full Stack Type Safety Monorepo

✅ “If the backend and frontend agree on a shape, keep it in one place.”


When building full-stack apps — especially in a monorepo — one of the trickiest (and most common) issues is keeping the API contract in sync between the frontend and backend. You make a small change to a DTO on the server, forget to update the frontend, and… boom, runtime error.

There’s a better way: use a shared schema that both ends can rely on. For me, that usually means using Zod.

Why Zod?

Zod is a TypeScript-first schema declaration library, which allows you to:

Essentially, it combines validation + typing in one — and works great for keeping the frontend and backend aligned.

The Problem This Solves

Let’s say your backend expects a payload like this:

// backend/src/dto/user.dto.ts
export type CreateUserDto = {
  email: string;
  password: string;
};

Now your frontend makes a POST request:

// ./src/api.ts
await fetch('/api/user', {
  method: 'POST',
  body: JSON.stringify({ email: 'a@b.com' }), // forgot password
});

Types don’t help here unless you’ve shared that shape, and validated it at runtime.


Step 1: Create a Shared @types/ Package

In a monorepo, I usually create a packages/types/ or packages/contract/ folder that contains schemas and shared logic.

// packages/contract/src/user.ts
import { z } from 'zod';

export const CreateUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(6),
});

export type CreateUser = z.infer<typeof CreateUserSchema>;

This schema gives you:

âś… A TypeScript type
âś… Runtime validation
âś… A single source of truth

Step 2: Use It in Your Backend

// api/src/routes/user.ts
import { CreateUserSchema } from '@your-org/contract/user';

app.post('/user', async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ errors: result.error.format() });
  }

  const { email, password } = result.data;
  // Continue with DB logic...
});

You validate the request at runtime using the same schema that defines the static type.

Step 3: Use It in Your Frontend

// web/src/lib/api.ts
import { CreateUser } from '@your-org/contract/user';

export async function createUser(data: CreateUser) {
  return await fetch('/api/user', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
}

Now your frontend can’t send an invalid payload — and if the shape changes, TypeScript will complain immediately.

Bonus: You Can Even Reuse It in Forms

If you’re using a form library like react-hook-form or sveltekit-superforms, Zod integrates directly:

import { zodResolver } from '@hookform/resolvers/zod';

useForm({
  resolver: zodResolver(CreateUserSchema),
});

One schema → everywhere.

Final Thoughts

I’ve tried other ways of syncing contracts i.e. OpenAPI, codegen, duplicating types — but using shared Zod schemas has been the simplest and most maintainable in practice.

It keeps the backend honest, the frontend safe, and your types all in one place.

If you’re working in a monorepo (or even across packages), it’s worth trying, and is my go to.

🏷 Tags

TypeScript · Zod · Monorepo · API Design · Full Stack Development · Type Safety