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.