TL;DR:
- tRPC lets TypeScript full-stack apps call server procedures directly from the client with end-to-end type inference — no REST endpoints, no GraphQL schema, no code gen step
- The server defines procedures (queries and mutations) with input validation via Zod; the client gets full autocomplete and type errors without any manual type sharing
- Best fit for TypeScript monorepos or Next.js/T3-stack projects; not suitable for APIs consumed by non-TypeScript clients
The Problem With Traditional API Layers
In a typical TypeScript full-stack application:
- You write a REST endpoint on the server
- You write a fetch call on the client
- You manually define TypeScript types for the request and response, or generate them from an OpenAPI spec
- When the endpoint changes, you update the types manually (or re-run the generator)
This is friction. Code generation from OpenAPI or GraphQL helps, but it adds tooling complexity and a build step. The types are derived from the schema, which is itself authored separately from the implementation — creating surface area for drift.
tRPC eliminates the API layer entirely for same-language stacks. The server and client share types directly at the TypeScript level. No schema, no generator, no manual type definitions.
How tRPC Works
tRPC is not a runtime protocol. It’s a TypeScript type system trick that uses function signatures as the source of truth.
You define procedures on the server. A procedure is a typed function with validated input:
// server/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
getUserById: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
return user;
}),
createUser: t.procedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ input }) => {
return db.user.create({ data: input });
}),
});
export type AppRouter = typeof appRouter;
The client imports the type of the router — not the implementation — and uses it through a typed client:
// client/app.tsx
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/router';
const trpc = createTRPCReact<AppRouter>();
function UserProfile({ userId }: { userId: string }) {
// Full type inference: data is typed as User | null
const { data } = trpc.getUserById.useQuery({ id: userId });
return <div>{data?.name}</div>;
}
trpc.getUserById.useQuery is a typed wrapper around React Query. The input type ({ id: string }) and return type (User | null) are inferred from the server procedure definition. If you change the return type on the server, the client immediately shows a type error. No codegen step, no manual sync.
At runtime, tRPC serialises the procedure call as an HTTP request (typically POST /api/trpc/getUserById with a JSON body) and deserialises the response. The transport is conventional HTTP — you can inspect it in browser devtools like any other request.
Installation and Setup
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
For Next.js (the most common setup):
npm install @trpc/next
Server setup (Next.js App Router):
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '../../../../server/router';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
Client setup:
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/router';
export const trpc = createTRPCReact<AppRouter>();
Provider in layout:
// app/layout.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { trpc } from '../utils/trpc';
import { httpBatchLink } from '@trpc/client';
const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
links: [httpBatchLink({ url: '/api/trpc' })],
});
export default function Layout({ children }) {
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
Input Validation with Zod
tRPC uses Zod for input validation. Every procedure can define an .input() schema:
const router = t.router({
searchProducts: t.procedure
.input(z.object({
query: z.string().min(1).max(100),
category: z.enum(['electronics', 'clothing', 'food']).optional(),
page: z.number().int().positive().default(1),
}))
.query(async ({ input }) => {
// input is fully typed: { query: string, category?: ..., page: number }
return search(input);
}),
});
Zod validates the input at the server boundary. Invalid inputs return a BAD_REQUEST error before the handler runs. The client’s TypeScript types are inferred from the same Zod schema, so the validation rules and type constraints are defined once and apply both to the runtime check and the TypeScript interface.
Context and Middleware
Procedures can use context for shared data like authenticated user info:
// Context created per-request
const t = initTRPC.context<{ user: User | null }>().create();
// Reusable auth middleware
const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { user: ctx.user } }); // user is non-null after this
});
const router = t.router({
getMyProfile: protectedProcedure.query(({ ctx }) => {
// ctx.user is typed as User (not null) here
return db.user.findUnique({ where: { id: ctx.user.id } });
}),
});
Batching and Performance
By default, tRPC batches multiple queries made simultaneously into a single HTTP request. If a page mounts and makes three useQuery calls, they’re batched into one request and split back out on the client. This reduces round trips without any explicit configuration.
For server-side rendering in Next.js, tRPC supports prefetching with dehydrate/hydrate to avoid client-side waterfalls on initial page load.
When to Use tRPC
Good fit:
- TypeScript monorepos where server and client share a codebase
- Next.js, SvelteKit, or similar full-stack frameworks
- Projects where the API is consumed only by your own TypeScript frontend
- Teams who find OpenAPI or GraphQL schema maintenance overhead frustrating
Not a good fit:
- APIs that need to be consumed by mobile apps, third-party services, or non-TypeScript clients — there’s no language-agnostic contract
- Public APIs that other developers will integrate with — there’s no schema to document or SDK to generate
- Systems where server and client are in separate repositories with no shared type infrastructure
The T3 stack (Next.js + TypeScript + tRPC + Prisma + Tailwind) has made tRPC the default API layer choice for a significant chunk of the TypeScript full-stack community. For projects that fit the TypeScript-monorepo model, the reduction in API friction is genuine — changes to server procedures immediately surface as type errors on the client, without a manual sync step or codegen run.