AboutBlogContact
Full-Stack EngineeringApril 17, 2026 10 min read 2

tRPC + Zod: End-to-End Type Safety Without Code Generation

AunimedaAunimeda
📋 Table of Contents

REST + OpenAPI + codegen is a tax you pay every time you change an endpoint. You update the route, regenerate the client, fix the type errors, update the tests. With a small team shipping fast, that ceremony compounds into real drag. tRPC eliminates the gap between server and client types by making them the same type — the server's router IS the client's type.

This isn't magic. It's TypeScript inference over a shared import. The tradeoff is that tRPC only works well in TypeScript-first monorepos or full-stack frameworks like Next.js. If you have mobile clients, third-party consumers, or non-TS services calling your API, REST is still the right answer for that surface. Within a TS full-stack app, tRPC is close to a free lunch.

This guide covers tRPC v11 with Next.js App Router, Zod for runtime validation, and the actual patterns you need for a real app — not the "hello world" tutorial.

Project Setup

npm install @trpc/server@11 @trpc/client@11 @trpc/react-query@11 @trpc/next@11
npm install @tanstack/react-query@5 zod

tRPC v11 ships with first-class support for React Query v5. The @trpc/react-query package wraps React Query hooks with full type inference — you get autocomplete on data, typed error handling, and proper loading states, all inferred from your Zod schemas.

The Core: Initializing tRPC

Create src/server/trpc.ts. This is the single source of your tRPC instance — everything else imports from here.

// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { ZodError } from 'zod';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { db } from '@/lib/db';

// Context is built fresh per request — this is where auth lives
export async function createContext(opts: { req: Request }) {
  const session = await getServerSession(authOptions);
  return {
    session,
    db,
    req: opts.req,
  };
}

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

const t = initTRPC.context<Context>().create({
  // Transform Zod validation errors into structured error payloads
  // instead of the generic TRPCError message
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

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

// Reusable auth middleware — attach to any procedure
const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({
      code: 'UNAUTHORIZED',
      message: 'You must be logged in to access this resource',
    });
  }
  // After this check, ctx.session.user is typed as non-null downstream
  return next({
    ctx: {
      ...ctx,
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});

export const protectedProcedure = t.procedure.use(isAuthed);

Two things worth noting here: First, errorFormatter is crucial — without it, Zod validation failures return a generic error message and the client has no idea which field failed. With it, you get error.data.zodError.fieldErrors with per-field messages. Second, the middleware pattern for protectedProcedure means you write auth protection once and compose it into any route — there's no "forgot to add auth middleware" class of bug.

Zod Schemas: Define Once, Use Everywhere

Put your Zod schemas somewhere both server and client can import them. In a Next.js monorepo, src/shared/schemas/ works well.

// src/shared/schemas/task.ts
import { z } from 'zod';

export const TaskStatus = z.enum(['todo', 'in_progress', 'done', 'cancelled']);

export const TaskSchema = z.object({
  id: z.string().cuid(),
  title: z.string().min(1, 'Title is required').max(255),
  description: z.string().max(2000).nullable(),
  status: TaskStatus,
  dueDate: z.date().nullable(),
  createdAt: z.date(),
  updatedAt: z.date(),
  userId: z.string().cuid(),
});

// Input for creating a task — user doesn't supply id/timestamps
export const CreateTaskInput = z.object({
  title: z.string().min(1).max(255),
  description: z.string().max(2000).optional(),
  status: TaskStatus.default('todo'),
  dueDate: z.string().datetime().optional().transform(v => v ? new Date(v) : null),
});

export const UpdateTaskInput = z.object({
  id: z.string().cuid(),
  title: z.string().min(1).max(255).optional(),
  description: z.string().max(2000).nullable().optional(),
  status: TaskStatus.optional(),
  dueDate: z.string().datetime().nullable().optional()
    .transform(v => v === undefined ? undefined : v ? new Date(v) : null),
});

export const TaskListInput = z.object({
  status: TaskStatus.optional(),
  page: z.number().int().min(1).default(1),
  limit: z.number().int().min(1).max(100).default(20),
  sortBy: z.enum(['createdAt', 'dueDate', 'title']).default('createdAt'),
  sortDir: z.enum(['asc', 'desc']).default('desc'),
});

// Paginated response wrapper — reusable
export const PaginatedResult = <T extends z.ZodTypeAny>(item: T) =>
  z.object({
    items: z.array(item),
    total: z.number(),
    page: z.number(),
    limit: z.number(),
    hasMore: z.boolean(),
  });

export type Task = z.infer<typeof TaskSchema>;
export type CreateTaskInput = z.infer<typeof CreateTaskInput>;
export type UpdateTaskInput = z.infer<typeof UpdateTaskInput>;

The transform calls on dueDate are important: JSON doesn't have a native Date type, so dates travel as ISO strings. Zod transforms them at the boundary so your business logic always works with Date objects, never strings.

The Task Router

// src/server/routers/task.ts
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { router, protectedProcedure } from '../trpc';
import {
  CreateTaskInput,
  UpdateTaskInput,
  TaskListInput,
  TaskSchema,
  PaginatedResult,
} from '@/shared/schemas/task';

export const taskRouter = router({
  list: protectedProcedure
    .input(TaskListInput)
    .output(PaginatedResult(TaskSchema))
    .query(async ({ ctx, input }) => {
      const { page, limit, status, sortBy, sortDir } = input;
      const offset = (page - 1) * limit;

      const where = {
        userId: ctx.session.user.id,
        ...(status ? { status } : {}),
      };

      const [items, total] = await Promise.all([
        ctx.db.task.findMany({
          where,
          orderBy: { [sortBy]: sortDir },
          skip: offset,
          take: limit,
        }),
        ctx.db.task.count({ where }),
      ]);

      return {
        items,
        total,
        page,
        limit,
        hasMore: offset + items.length < total,
      };
    }),

  byId: protectedProcedure
    .input(z.object({ id: z.string().cuid() }))
    .query(async ({ ctx, input }) => {
      const task = await ctx.db.task.findUnique({
        where: { id: input.id },
      });

      if (!task) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'Task not found' });
      }

      // Ownership check — never trust the client
      if (task.userId !== ctx.session.user.id) {
        throw new TRPCError({ code: 'FORBIDDEN' });
      }

      return task;
    }),

  create: protectedProcedure
    .input(CreateTaskInput)
    .mutation(async ({ ctx, input }) => {
      return ctx.db.task.create({
        data: {
          ...input,
          userId: ctx.session.user.id,
          // Never take userId from input — always from authenticated context
        },
      });
    }),

  update: protectedProcedure
    .input(UpdateTaskInput)
    .mutation(async ({ ctx, input }) => {
      const { id, ...data } = input;

      const existing = await ctx.db.task.findUnique({ where: { id } });
      if (!existing || existing.userId !== ctx.session.user.id) {
        throw new TRPCError({ code: 'NOT_FOUND' });
      }

      return ctx.db.task.update({
        where: { id },
        data,
      });
    }),

  delete: protectedProcedure
    .input(z.object({ id: z.string().cuid() }))
    .mutation(async ({ ctx, input }) => {
      const existing = await ctx.db.task.findUnique({ where: { id: input.id } });
      if (!existing || existing.userId !== ctx.session.user.id) {
        throw new TRPCError({ code: 'NOT_FOUND' });
      }

      await ctx.db.task.delete({ where: { id: input.id } });
      return { success: true };
    }),
});

The .output() call on list is optional but powerful: tRPC will strip any fields from the response that aren't in the schema. This prevents accidentally leaking internal fields (like passwordHash) if your DB model contains them. Think of it as a runtime firewall on your response shape.

Root Router and Next.js Handler

// src/server/routers/_app.ts
import { router } from '../trpc';
import { taskRouter } from './task';
import { userRouter } from './user'; // add other routers here

export const appRouter = router({
  task: taskRouter,
  user: userRouter,
});

export type AppRouter = typeof appRouter; // This is the only export clients need
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/trpc';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => createContext({ req }),
    onError:
      process.env.NODE_ENV === 'development'
        ? ({ path, error }) => {
            console.error(`tRPC error on ${path}:`, error);
          }
        : undefined,
  });

export { handler as GET, handler as POST };

Client Setup and React Query Integration

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

export const trpc = createTRPCReact<AppRouter>();
// src/providers/TRPCProvider.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from '@/lib/trpc';

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000, // 1 minute
        retry: (failureCount, error: any) => {
          // Don't retry on 4xx errors
          if (error?.data?.httpStatus >= 400 && error?.data?.httpStatus < 500) {
            return false;
          }
          return failureCount < 3;
        },
      },
    },
  }));

  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        loggerLink({ enabled: () => process.env.NODE_ENV === 'development' }),
        httpBatchLink({
          url: '/api/trpc',
          // Attach auth headers if not using cookies
          headers() {
            return {};
          },
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}

Using It in a Component

This is where tRPC earns its keep. Note how data is fully typed — hover over it in your editor and you see the exact shape from your Zod schema, including the nullable() fields.

// src/components/TaskList.tsx
'use client';
import { trpc } from '@/lib/trpc';
import { useState } from 'react';

export function TaskList() {
  const [page, setPage] = useState(1);
  const utils = trpc.useUtils();

  const { data, isLoading, error } = trpc.task.list.useQuery({
    page,
    limit: 20,
    sortBy: 'createdAt',
    sortDir: 'desc',
  });

  const deleteMutation = trpc.task.delete.useMutation({
    onSuccess: () => {
      // Invalidate the list query after deletion
      utils.task.list.invalidate();
    },
    onError: (err) => {
      // err.data.zodError is typed — no casting needed
      console.error('Delete failed:', err.message);
    },
  });

  const createMutation = trpc.task.create.useMutation({
    onSuccess: (newTask) => {
      // Optimistic update: add to cache without refetch
      utils.task.list.setData({ page: 1, limit: 20, sortBy: 'createdAt', sortDir: 'desc' },
        (old) => old ? { ...old, items: [newTask, ...old.items] } : old
      );
    },
  });

  if (isLoading) return <div>Loading...</div>;

  // TypeScript knows error.data.zodError exists due to our errorFormatter
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {data?.items.map(task => (
        <li key={task.id}>
          {task.title}
          <button
            onClick={() => deleteMutation.mutate({ id: task.id })}
            disabled={deleteMutation.isPending}
          >
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

Where TypeScript Catches You

Try passing a wrong status to list:

// TypeScript error: Type '"invalid"' is not assignable to type 'TaskStatus | undefined'
trpc.task.list.useQuery({ status: 'invalid' });

// TypeScript error: Argument of type '{ title: 123 }' is not assignable...
// Property 'title': Type 'number' is not assignable to type 'string'
trpc.task.create.mutate({ title: 123 });

These errors appear in your editor before you ever hit the network. Contrast with REST+OpenAPI: you update the spec, re-run openapi-typescript-codegen, potentially deal with generated code drift, and only catch the mismatch if the generator ran. tRPC's type checking is structural and live.

tRPC vs REST+OpenAPI: Honest Assessment

tRPC is not universally better. REST+OpenAPI wins when:

  • You have non-TypeScript consumers (mobile apps, third-party integrators)
  • Your team has strong REST conventions and good OpenAPI tooling already in place
  • You need HTTP-level caching (GET with URL params cache; tRPC batches over POST)
  • You want to expose a public API that developers can explore with Swagger UI

tRPC wins when:

  • Full-stack TypeScript, single team, fast iteration
  • You're building internal tooling or a SaaS where you control all clients
  • The codegen ceremony is actually slowing you down
  • You want compile-time guarantees that a server change won't silently break the client

Need a type-safe full-stack architecture that moves fast without breaking things? Aunimeda's team builds production TypeScript systems with tRPC, Zod, and proper error boundaries. Explore our custom software development services or reach out directly.

Read Also

Web Vitals & Lighthouse 100: Practical Optimization Guide 2026aunimeda
Development

Web Vitals & Lighthouse 100: Practical Optimization Guide 2026

Achieving Lighthouse 100 on a real-world production Next.js app — not a blank page. Covers LCP, INP (replaced FID in 2024), CLS, TTFB, font optimization, image optimization, JS bundle analysis, and CSS critical path — with specific code changes.

Node.js vs Bun vs Deno in 2026: Runtime Comparison with Real Benchmarksaunimeda
Development

Node.js vs Bun vs Deno in 2026: Runtime Comparison with Real Benchmarks

Bun 1.x is production-stable. Deno 2.0 supports npm packages. Node.js 22 has native TypeScript. The runtime landscape changed. Here's what the numbers actually show and when each runtime makes sense for real projects.

Clean Architecture in Node.js: A Practical Guide Without the Academic Fluffaunimeda
Development

Clean Architecture in Node.js: A Practical Guide Without the Academic Fluff

Clean Architecture sounds great in theory. In practice, most implementations add complexity without benefit. This guide shows the pattern that actually works in Node.js — dependency inversion, use cases, and repository pattern with real, runnable code.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Get Consultation All articles