6 min read

Type‑Safe API Contracts with tRPC & Zod: End‑to‑End Validation in Next.js

Introduction

When you build a full‑stack application with Next.js, you often end up writing the same data shape twice: once for the client (TypeScript interfaces) and once for the server (runtime validation). This duplication is a source of bugs, extra maintenance, and friction during refactors.

Enter tRPC – a TypeScript‑first RPC framework that lets you expose server functions as if they were local calls, and Zod – a schema‑first validation library that can infer TypeScript types from runtime schemas. By combining the two, you get:

  • Zero‑gap type safety – the client automatically receives the exact types of the server procedures.
  • Runtime validation – Zod guarantees that the data arriving at the server (or leaving it) conforms to the expected shape.
  • Single source of truth – one schema definition drives both compile‑time types and runtime checks.

In this article we’ll walk through a realistic Next.js project, show how to set up tRPC with Zod, and demonstrate end‑to‑end validation for a typical CRUD workflow.


1. Project Setup

# Create a fresh Next.js app (app router)
npx create-next-app@latest trpc-zod-demo --ts
cd trpc-zod-demo

# Install dependencies
npm i @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
npm i -D @types/node
Why @tanstack/react-query?
tRPC’s React bindings are built on top of React Query, giving you caching, retries, and devtools out of the box.

Add a src/server/trpc.ts file that creates the tRPC context and router base:

// src/server/trpc.ts
import { initTRPC } from '@trpc/server';
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
import superjson from 'superjson';

export const createContext = ({
  req,
  res,
}: CreateNextContextOptions) => ({ req, res });

export type Context = Awaited<ReturnType<typeof createContext>>;

const t = initTRPC.context<Context>().create({
  transformer: superjson, // handles Dates, Maps, etc.
});

export const router = t.router;
export const publicProcedure = t.procedure;

2. Defining a Contract with Zod

Suppose we are building a todo API. The data model is simple:

// src/server/schemas/todo.ts
import { z } from 'zod';

export const TodoSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1, 'Title cannot be empty'),
  completed: z.boolean(),
  // Optional due date – must be a valid ISO string if present
  dueDate: z.string().datetime().optional(),
});

export type Todo = z.infer<typeof TodoSchema>;

Notice that the Zod schema is the only place where we describe the shape of a todo. From this schema we can derive the TypeScript type (Todo) and also use the same schema for runtime validation.


3. Building the tRPC Router

Create a router that implements the CRUD operations. Each procedure receives the input schema, validates it, and returns data that also conforms to a Zod schema.

// src/server/routers/todo.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
import { TodoSchema, Todo } from '../schemas/todo';

// In‑memory store for demo purposes
const store = new Map<string, Todo>();

export const todoRouter = router({
  // GET /todos
  list: publicProcedure
    .output(z.array(TodoSchema))
    .query(() => {
      return Array.from(store.values());
    }),

  // GET /todos/:id
  get: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .output(TodoSchema)
    .query(({ input }) => {
      const todo = store.get(input.id);
      if (!todo) throw new Error('Todo not found');
      return todo;
    }),

  // POST /todos
  create: publicProcedure
    .input(
      TodoSchema.omit({ id: true }) // client does not send id
    )
    .output(TodoSchema)
    .mutation(({ input }) => {
      const id = crypto.randomUUID();
      const newTodo: Todo = { id, ...input };
      store.set(id, newTodo);
      return newTodo;
    }),

  // PATCH /todos/:id
  update: publicProcedure
    .input(
      z.object({
        id: z.string().uuid(),
        data: TodoSchema.partial().omit({ id: true }),
      })
    )
    .output(TodoSchema)
    .mutation(({ input }) => {
      const existing = store.get(input.id);
      if (!existing) throw new Error('Todo not found');
      const updated = { ...existing, ...input.data };
      // Validate the merged object again (defensive)
      const parsed = TodoSchema.parse(updated);
      store.set(input.id, parsed);
      return parsed;
    }),

  // DELETE /todos/:id
  delete: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .output(z.boolean())
    .mutation(({ input }) => {
      return store.delete(input.id);
    }),
});

Key points:

  • input and output are both Zod schemas. tRPC automatically validates incoming data and serializes outgoing data.
  • The partial() helper lets us accept partial updates while still guaranteeing that the final object matches TodoSchema.
  • Because we use superjson as the transformer, Date objects (if we ever add them) survive the network round‑trip.

Now expose the router via a Next.js API route:

// src/pages/api/trpc/[trpc].ts
import * as trpcNext from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';
import { createContext } from '../../../server/trpc';

export default trpcNext.createNextApiHandler({
  router: appRouter,
  createContext,
});

And combine all routers:

// src/server/routers/_app.ts
import { router } from '../trpc';
import { todoRouter } from './todo';

export const appRouter = router({
  todo: todoRouter,
});

// Export type definition of API
export type AppRouter = typeof appRouter;

4. Client‑Side Integration

Create a tRPC client that lives in the React tree. With the app router we get fully typed hooks.

// src/utils/trpc.ts
import { httpBatchLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/routers/_app';
import superjson from 'superjson';

export const trpc = createTRPCReact<AppRouter>();

export const trpcClient = trpc.createClient({
  transformer: superjson,
  links: [
    httpBatchLink({
      url: '/api/trpc',
    }),
  ],
});

Wrap the application in the provider (e.g., in src/app/layout.tsx for the App Router):

// src/app/layout.tsx
import './globals.css';
import { trpc, trpcClient } from '../utils/trpc';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <trpc.Provider client={trpcClient} queryClient={queryClient}>
          {children}
        </trpc.Provider>
      </body>
    </html>
  );
}

4.1 Using the Hooks

// src/app/todos/page.tsx
'use client';
import { trpc } from '../../utils/trpc';
import { useState } from 'react';

export default function TodoPage() {
  const utils = trpc.useContext();
  const { data: todos, isLoading } = trpc.todo.list.useQuery();
  const createTodo = trpc.todo.create.useMutation({
    onSuccess: () => utils.todo.list.invalidate(),
  });

  const [title, setTitle] = useState('');

  const handleAdd = async () => {
    if (!title.trim()) return;
    await createTodo.mutateAsync({ title, completed: false });
    setTitle('');
  };

  if (isLoading) return <p>Loading…</p>;

  return (
    <section>
      <h1>My Todos</h1>
      <ul>
        {todos?.map((t) => (
          <li key={t.id}>
            {t.title} {t.completed ? '✅' : '❌'}
          </li>
        ))}
      </ul>

      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="New todo"
      />
      <button onClick={handleAdd} disabled={createTodo.isLoading}>
        Add
      </button>
    </section>
  );
}

Because the client hook is generated from the server router, TypeScript will warn you if you try to pass an invalid payload (e.g., a missing title). At runtime, Zod will reject malformed data before it ever reaches the business logic.


5. End‑to‑End Validation in Action

5.1 Simulating a Bad Request

Open the browser console and run:

await fetch('/api/trpc/todo.create', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    // Intentionally omit `title`
    completed: false,
  }),
});

The server responds with a 400 and a JSON body containing Zod’s error details:

{
  "error": {
    "code": "BAD_REQUEST",
    "message": "Invalid input",
    "data": {
      "zodError": {
        "issues": [
          {
            "code": "invalid_type",
            "expected": "string",
            "received": "undefined",
            "path": ["title"],
            "message": "Required"
          }
        ]
      }
    }
  }
}

Even though the client code compiled (because we bypassed the generated hook), the server never processes the request – the contract protects the backend.

5.2 Guarding Against Over‑Posting

Suppose a malicious client sends an extra field isAdmin: true. Because we explicitly define the input schema, Zod strips unknown keys by default (strict mode). The request will be rejected unless we deliberately allow unknown keys, which forces us to think about security at the contract level.


6. Advanced Patterns

6.1 Reusing Schemas Across Layers

You can place all shared schemas in a src/shared/schemas folder and import them both in the server and in a Node‑only validation layer (e.g., for background jobs). This eliminates duplication across micro‑services that share the same data contract.

6.2 Pagination & Meta‑Data

For list endpoints, combine Zod with generic helpers:

const Paginated = <T extends z.ZodTypeAny>(item: T) =>
  z.object({
    items: z.array(item),
    total: z.number(),
    page: z.number(),
    pageSize: z.number(),
  });

export const todoListOutput = Paginated(TodoSchema);

Now the list procedure can return { items, total, page, pageSize } while the client receives a fully typed pagination object.

6.3 Error Mapping

tRPC automatically maps thrown Error objects to RPC errors, but you can create custom error types that carry Zod validation details:

import { TRPCError } from '@trpc/server';

if (!todo) {
  throw new TRPCError({
    code: 'NOT_FOUND',
    message: `Todo ${input.id} does not exist`,
  });
}

On the client, useQuery or useMutation will surface error objects with code and message, enabling consistent UI handling.


7. Testing the Contract

Because the contract lives in code, you can write unit tests that validate both the TypeScript types and the Zod schemas.

import { expectTypeOf } from 'expect-type';
import { TodoSchema } from '../server/schemas/todo';

test('TodoSchema infers correct type', () => {
  type Inferred = z.infer<typeof TodoSchema>;
  expectTypeOf<Inferred>().toMatchTypeOf<{
    id: string;
    title: string;
    completed: boolean;
    dueDate?: string;
  }>();
});

test('create mutation rejects invalid payload', async () => {
  const client = trpcClient; // use the same client as in prod
  await expect(
    client.todo.create.mutate({ completed: false } as any)
  ).rejects.toThrow('Invalid input');
});

These tests give you confidence that the contract stays in sync as the application evolves.


8. Performance Considerations

  • Batching – The httpBatchLink groups multiple RPC calls into a single HTTP request, reducing round‑trips for components that fire several queries on mount.
  • SuperJSON – Handles complex data (Dates, Maps) without manual serialization, but adds a small overhead. Measure payload size if you’re sending large binary blobs.
  • Cache Invalidation – Use utils.todo.list.invalidate() after mutations to keep the UI fresh without refetching everything manually.

9. Summary

By pairing tRPC with Zod in a Next.js project you achieve:

Benefit How it works
Zero‑gap type safety Server procedures expose exact TypeScript types to the client.
Runtime validation Zod validates inbound/outbound data, preventing malformed payloads.
Single source of truth One schema definition drives both compile‑time and runtime checks.
Developer ergonomics Auto‑generated React Query hooks, built‑in caching, and error handling.
Security Unknown fields are rejected, reducing attack surface.

The pattern scales from a tiny todo app to large, multi‑team codebases. When you need to evolve an API, you only touch the Zod schema – the rest of the stack updates automatically, and TypeScript will highlight any mismatches before they ship.

Give it a try in your next Next.js project and experience the confidence that comes from truly type‑safe, end‑to‑end validated APIs.