Back to blog

Building Type-Safe APIs with Next.js and Zod

How I structure full-stack TypeScript APIs with runtime validation, type inference, and zero duplication between client and server.

4 min read
Gagan Deep Singh

Gagan Deep Singh

Founder | GLINR Studios


One of my biggest frustrations as a full-stack developer has always been keeping types in sync between the API and the frontend. You define a response shape on the server, then manually recreate the same type on the client. Inevitably, they drift apart.

Here's how I solved this with Zod schemas as the single source of truth — no code generation, no duplication, just pure TypeScript inference.

The Problem

Consider a typical API route in Next.js:

app/api/projects/route.ts
export async function GET() {
  const projects = await db.project.findMany();
  return Response.json(projects); // What shape is this?
}

On the client, you'd do something like:

const res = await fetch("/api/projects");
const data = await res.json(); // any 😬

That any is a ticking time bomb. You can cast it, but casts lie. You can write a separate interface, but it'll drift.

The Solution: Zod as the Contract

Define the schema once, derive everything from it:

lib/schemas/project.ts
import { z } from "zod";
 
export const projectSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  description: z.string().optional(),
  status: z.enum(["active", "archived", "draft"]),
  tags: z.array(z.string()),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});
 
// Infer the type — zero duplication
export type Project = z.infer<typeof projectSchema>;
 
// Response wrapper
export const projectListResponse = z.object({
  projects: z.array(projectSchema),
  total: z.number(),
});
 
export type ProjectListResponse = z.infer<typeof projectListResponse>;

Now the API route validates before responding:

app/api/projects/route.ts
import { projectListResponse } from "@/lib/schemas/project";
 
export async function GET() {
  const projects = await db.project.findMany();
 
  // Runtime validation — catches schema drift immediately
  const data = projectListResponse.parse({
    projects,
    total: projects.length,
  });
 
  return Response.json(data);
}

And the client gets type-safe parsing for free:

lib/api.ts
import { projectListResponse } from "@/lib/schemas/project";
 
export async function getProjects() {
  const res = await fetch("/api/projects");
 
  if (!res.ok) {
    throw new Error(`Failed to fetch: ${res.status}`);
  }
 
  return projectListResponse.parse(await res.json());
}

Input Validation Too

The same pattern works for request bodies. Here's a create endpoint:

lib/schemas/project.ts
export const createProjectInput = projectSchema
  .omit({ id: true, createdAt: true, updatedAt: true })
  .extend({
    tags: z.array(z.string()).default([]),
  });
 
export type CreateProjectInput = z.infer<typeof createProjectInput>;
app/api/projects/route.ts
import { createProjectInput } from "@/lib/schemas/project";
 
export async function POST(request: Request) {
  const body = await request.json();
 
  // Validates and strips unknown fields
  const input = createProjectInput.parse(body);
 
  const project = await db.project.create({ data: input });
  return Response.json(project, { status: 201 });
}

If someone sends { name: "", status: "invalid" }, Zod throws a structured error with field-level details — no manual if (!name) checks needed.

Error Handling

Wrap everything in a reusable handler:

lib/api-handler.ts
import { ZodError, type ZodSchema } from "zod";
 
export function validateBody<T>(schema: ZodSchema<T>, data: unknown): T {
  try {
    return schema.parse(data);
  } catch (error) {
    if (error instanceof ZodError) {
      const details = error.errors.map((e) => ({
        field: e.path.join("."),
        message: e.message,
      }));
      throw new ValidationError(details);
    }
    throw error;
  }
}

On the client, these errors map cleanly to form field errors — no parsing strings or guessing which field failed.

Why This Works

  • Single source of truth — the Zod schema defines the shape once
  • Runtime safety — catches type mismatches in development, not production
  • TypeScript inferencez.infer gives you the type without writing it twice
  • Composable.omit(), .pick(), .extend() let you derive input/output types from base schemas
  • Self-documenting — the schema is the documentation

The Tradeoff

There's a cost: runtime parsing adds a few microseconds per request. For 99% of apps, this is irrelevant. For hot paths processing thousands of requests per second, you can skip validation in production and only run it in development with an environment flag.

const validate = process.env.NODE_ENV !== "production";
 
const data = validate
  ? projectListResponse.parse(raw)
  : (raw as ProjectListResponse);

But honestly, I've never needed this. The safety is worth far more than the nanoseconds.

What I Use This For

At GLINCKER, every API contract is defined this way. It eliminated an entire class of bugs where the mobile app expected one shape and the backend returned another. At Marriott, I've used similar patterns with Java's Bean Validation — but Zod's TypeScript inference makes the DX significantly smoother.

If you're building full-stack TypeScript, stop writing types twice. Let Zod do it.


Contact