Why tRPC Beats Traditional API Approaches in TypeScript Stacks
tRPC eliminates the most common source of bugs in full-stack TypeScript applications: mismatched contracts between client and server. Instead of writing REST endpoints with separate OpenAPI specs or maintaining a GraphQL schema, you define procedures once in TypeScript and get full type inference on both sides.
With REST you manually create fetch wrappers and keep types in sync. With GraphQL you add resolver boilerplate and runtime query validation. tRPC uses the TypeScript compiler itself as the contract layer. When you change a procedure's input or output shape, your frontend code fails to compile immediately. This tight coupling is a feature when you own both client and server, which is the case for most Next.js startups and indie products.
The trade-off is obvious: tRPC is not suitable for public APIs consumed by mobile apps or third-party developers who cannot import your tRPC client. For internal admin panels, SaaS dashboards, and feature-rich web apps it removes hundreds of lines of glue code. In production applications I have seen the combination of tRPC and Zod cut API-related bugs by removing the entire class of "property does not exist" runtime errors.
Project Setup with Next.js 15 and tRPC v10
Start with the official Next.js 15 template using the App Router:
npx create-next-app@latest taskflow --typescript --tailwind --eslint --app --yes
cd taskflow
npm install @trpc/server@10.45.2 @trpc/client@10.45.2 @trpc/react-query@10.45.2 @trpc/next@10.45.2 zod@3.23.8
Also install the development dependency for React Query devtools if you want them:
npm install --save-dev @tanstack/react-query-devtools
Ensure your tsconfig.json has "strict": true and "moduleResolution": "bundler". Next.js 15 defaults work well. The exact versions above were confirmed compatible at the time of writing; always check the tRPC compatibility table before upgrading Next.js major versions.
tRPC v10 uses a functional approach with minimal magic. You will create one initialization file that exports a typed router factory and procedure helpers. This central file becomes the single source of truth for your entire API contract.
Initializing the tRPC Context and Router Factory
Create src/server/trpc.ts:
import { initTRPC, TRPCError } from '@trpc/server';
import { type CreateNextContextOptions } from '@trpc/server/adapters/next';
import superjson from 'superjson';
import { ZodError } from 'zod';
export const createTRPCContext = (opts: CreateNextContextOptions) => {
const { req, res } = opts;
const userId = req.headers['x-user-id'] as string | undefined; // simplified for demo
return { userId, req };
};
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
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;
export const middleware = t.middleware;
The context function runs on every request. We extract a fake userId from a header for demonstration. In production this would come from verifying a JWT or session cookie. SuperJSON handles Date objects and complex types automatically. The error formatter surfaces Zod validation errors to the client with full field information.
Defining Your First Procedures and Routers
Create src/server/routers/task.ts for our example application. We will build a task manager with list, create, and toggle operations.
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';
type Task = {
id: string;
title: string;
completed: boolean;
createdAt: Date;
};
let tasks: Task[] = []; // in-memory for demo. Replace with Drizzle or Prisma in production.
export const taskRouter = router({
list: publicProcedure.query(() => {
return tasks;
}),
create: publicProcedure
.input(z.object({ title: z.string().min(3).max(120) }))
.mutation(({ input }) => {
const task: Task = {
id: Date.now().toString(36),
title: input.title,
completed: false,
createdAt: new Date(),
};
tasks.push(task);
return task;
}),
toggle: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(({ input, ctx }) => {
const task = tasks.find(t => t.id === input.id);
if (!task) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
task.completed = !task.completed;
return task;
}),
});
Notice the protectedProcedure we have not defined yet. It will be created with middleware in the next section. The input schemas are Zod objects. tRPC automatically validates at runtime and returns structured errors. The list procedure needs no input, so we omit the .input() call.
Creating Protected Procedures with Middleware
Back in src/server/trpc.ts, add the following after the publicProcedure definition:
const isAuthed = middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
...ctx,
userId: ctx.userId, // now typed as string
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);
Middleware runs before the procedure. If the check fails, execution stops and the client receives a typed error with status UNAUTHORIZED. Because we pass a new context object, subsequent procedures receive a narrowed userId: string type. You can chain multiple middlewares for rate limiting, tenant isolation, or logging. This pattern scales cleanly as your authorization rules grow.
Merging Routers and Exposing via App Router
Create src/server/routers/_app.ts:
import { router } from '../trpc';
import { taskRouter } from './task';
export const appRouter = router({
task: taskRouter,
});
export type AppRouter = typeof appRouter;
Now create the API handler at src/app/api/trpc/[trpc]/route.ts:
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { createTRPCContext } from '@/server/trpc';
import { appRouter } from '@/server/routers/_app';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
});
export { handler as GET, handler as POST };
Next.js 15 App Router uses the Web Request/Response API, so the fetch adapter is the correct choice. The endpoint option must match your folder structure. This single route handles every procedure via POST by default, with built-in batching.
Wiring Up tRPC React Query in the Client
Create src/trpc/react.tsx:
import { createTRPCReact } from '@trpc/react-query';
import { type AppRouter } from '@/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>({});
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
Wrap your root layout or the relevant part of the tree with <TRPCProvider>. Batching is enabled automatically; multiple queries in the same event loop become a single HTTP request. This reduces latency on data-heavy dashboards.
Consuming Procedures in Components
Inside any client component you can now write:
'use client';
import { trpc } from '@/trpc/react';
export default function TaskList() {
const { data: tasks, isLoading } = trpc.task.list.useQuery();
const createTask = trpc.task.create.useMutation({
onSuccess: () => {
utils.task.list.invalidate();
},
});
if (isLoading) return <p>Loading…</p>;
return (
<div>
{tasks?.map(task => (
<div key={task.id}>{task.title} – {task.completed ? 'Done' : 'Open'}</div>
))}
<button => createTask.mutate({ title: 'New task' })}>
Add task
</button>
</div>
);
}
The useQuery and useMutation hooks are fully typed. The return type of tasks matches exactly what the backend returns. No separate types file is needed. When you add a new field to the Task type, both frontend and backend will know about it or the code will not compile.
Using tRPC in Server Components
Next.js 15 Server Components can call procedures directly without the React hooks. Create a server component that uses the server-side caller:
import { createTRPCContext } from '@/server/trpc';
import { appRouter } from '@/server/routers/_app';
async function ServerTaskCount() {
const caller = appRouter.createCaller(await createTRPCContext({ req: {} as any, res: {} as any }));
const allTasks = await caller.task.list();
return <p>Total tasks: {allTasks.length}</p>;
}
This approach bypasses HTTP entirely when used inside the same Next.js process. It is useful for metadata generation, static rendering, and SEO-critical pages. The trade-off is that you lose automatic caching and invalidation that React Query provides on the client.
Error Handling and Debugging
tRPC surfaces errors with codes such as BAD_REQUEST, UNAUTHORIZED, NOT_FOUND, INTERNAL_SERVER_ERROR. The Zod integration we added earlier returns a zodError field containing fieldErrors when validation fails. On the frontend you can handle these specifically:
const createTask = trpc.task.create.useMutation({
onError: (err) => {
if (err.data?.zodError) {
console.log('Validation issues:', err.data.zodError.fieldErrors);
}
},
});
For logging in production, add a middleware that catches every procedure call and sends metrics or errors to Sentry or your preferred observability tool. Because every procedure is typed, you can also write integration tests easily with Vitest by calling the router directly.
Performance Benefits and Trade-offs
HTTP batching reduces the number of round-trips. In a dashboard that loads 6 separate resources, tRPC turns that into one request. The payload uses SuperJSON so you keep full type fidelity without manual serialization. Tree-shaking works because unused procedures are never imported on the client.
Trade-offs exist. The tight coupling means you cannot easily expose the same API to a React Native mobile team without shipping them a TypeScript client. Versioning is harder than with REST because there is no URL-based contract. Most teams solve this by deploying the frontend and backend together, which aligns with the Next.js model anyway.
Compared with TanStack Start or manual React Query + Zodios, tRPC requires the least boilerplate. The compiled output size for the client is small because only the procedures you import are bundled.
Scaling the API as Your Product Grows
When your router file grows beyond 300 lines, split it into feature routers and merge them in appRouter. You can nest routers arbitrarily: user.profile.update is perfectly valid. For database access, inject your Drizzle or Prisma client through context so every procedure has typed access.
Rate limiting can be added as another middleware that uses Upstash Redis. Authorization rules for multi-tenant apps fit naturally inside protected procedures. Because the entire contract lives in TypeScript, refactoring a field name across ten procedures and thirty frontend calls becomes a single editor rename operation.
For very high-traffic public endpoints, consider keeping a small REST layer and using tRPC only for authenticated product surfaces. This hybrid approach gives you the best of both worlds.
The complete task manager example above can be extended with infinite scrolling by using useInfiniteQuery, file uploads by streaming through the mutation, and real-time updates by adding a subscription procedure with a WebSocket adapter. The foundation shown here has been used successfully in production SaaS products handling thousands of daily active users.
Replace the in-memory array with a proper database (Drizzle ORM works especially cleanly with tRPC because both are TypeScript-first) and add proper session handling using either Lucia, NextAuth v5, or Clerk. The patterns remain identical.
