tRPC Router
What is tRPC?
tRPC is a framework for building end-to-end type-safe APIs in TypeScript full-stack applications. Unlike REST or GraphQL, tRPC requires no code generation, schema definitions, or serialization layers — you define functions on the server and the client automatically gets full type inference. When you change an API signature, the TypeScript compiler immediately flags errors on the client side, fundamentally eliminating runtime type errors at the API boundary.
tRPC works particularly well with full-stack TypeScript frameworks like Next.js and Remix, but it also integrates with any Node.js server framework such as Express or Fastify.
Quick Start
Install Packages
npm install @trpc/server @trpc/client @trpc/react-query zod
npm install @tanstack/react-query
Initialize tRPC
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import superjson from 'superjson';
const t = initTRPC.context<Context>().create({
transformer: superjson,
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
Create Your First Router
// server/routers/greeting.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
export const greetingRouter = router({
hello: publicProcedure
.input(z.object({ name: z.string().optional() }))
.query(({ input }) => {
return { message: `Hello ${input.name ?? 'world'}!` };
}),
});
Export the AppRouter Type
// server/root.ts
import { router } from './trpc';
import { greetingRouter } from './routers/greeting';
export const appRouter = router({
greeting: greetingRouter,
});
// Export type only — no runtime code is exported
export type AppRouter = typeof appRouter;
Core Concepts
Procedures
A procedure is a function exposed to the client via tRPC. There are three types: query (read data), mutation (write data), and subscription (real-time data streams).
Query — Read Data
// List users with cursor-based pagination
userList: publicProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(20),
cursor: z.string().nullish(),
}))
.query(async ({ input, ctx }) => {
const items = await ctx.db.user.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
let nextCursor: string | undefined;
if (items.length > input.limit) {
nextCursor = items.pop()!.id;
}
return { items, nextCursor };
}),
Mutation — Write Data
// Create a new user
createUser: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['USER', 'ADMIN']).default('USER'),
}))
.mutation(async ({ input, ctx }) => {
const user = await ctx.db.user.create({ data: input });
return user;
}),
Subscription — Real-Time Data
import { observable } from '@trpc/server/observable';
onNewMessage: publicProcedure
.input(z.object({ channelId: z.string() }))
.subscription(({ input }) => {
return observable<Message>((emit) => {
const handler = (msg: Message) => emit.next(msg);
messageEmitter.on(`channel:${input.channelId}`, handler);
return () => {
messageEmitter.off(`channel:${input.channelId}`, handler);
};
});
}),
Input Validation with Zod
tRPC integrates deeply with Zod. All inputs are validated at runtime and types are automatically inferred.
Basic Schema
const createPostInput = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
published: z.boolean().default(false),
});
Nested Objects
const addressSchema = z.object({
street: z.string(),
city: z.string(),
zip: z.string().regex(/^\d{5,6}$/),
country: z.string().length(2),
});
const userInput = z.object({
name: z.string(),
address: addressSchema,
billingAddress: addressSchema.optional(),
});
Array Validation
const batchInput = z.object({
ids: z.array(z.string().uuid()).min(1).max(50),
tags: z.array(z.string()).max(10).default([]),
});
Composing Inputs (.input().input())
// Multiple .input() calls automatically merge schemas
updatePost: protectedProcedure
.input(z.object({ id: z.string() }))
.input(z.object({ title: z.string(), content: z.string() }))
.mutation(({ input }) => {
// input type: { id: string; title: string; content: string }
}),
Context
Context is an object shared across every request, typically containing database connections, the current user session, and other dependencies. It is accessed via ctx in middleware and procedures.
Creating Context
// server/context.ts
import { inferAsyncReturnType } from '@trpc/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { getServerSession } from 'next-auth';
import { prisma } from './db';
export async function createContext({ req, res }: CreateNextContextOptions) {
const session = await getServerSession(req, res, authOptions);
return {
db: prisma,
session,
req,
res,
};
}
export type Context = inferAsyncReturnType<typeof createContext>;
Accessing Context in Procedures
getProfile: protectedProcedure.query(({ ctx }) => {
// ctx.session.user is guaranteed to exist in protectedProcedure
return ctx.db.user.findUnique({
where: { id: ctx.session.user.id },
select: { id: true, name: true, email: true, avatar: true },
});
}),
Middleware
Middleware runs before or after a procedure executes. Common uses include authentication checks, logging, and performance monitoring.
Auth Middleware (Protected Procedure)
const isAuthed = middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
...ctx,
session: { ...ctx.session, user: ctx.session.user },
},
});
});
export const protectedProcedure = publicProcedure.use(isAuthed);
Logging Middleware
const logger = middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
if (result.ok) {
console.log(`[${type}] ${path} - ${duration}ms OK`);
} else {
console.error(`[${type}] ${path} - ${duration}ms ERROR`);
}
return result;
});
Timing Middleware
const timing = middleware(async ({ path, next }) => {
const start = performance.now();
const result = await next();
const elapsed = performance.now() - start;
if (elapsed > 1000) {
console.warn(`Slow procedure: ${path} took ${elapsed.toFixed(0)}ms`);
}
return result;
});
Chaining Middleware
// Middleware executes in .use() call order
export const adminProcedure = publicProcedure
.use(logger)
.use(timing)
.use(isAuthed)
.use(isAdmin); // each middleware can extend/narrow the ctx type
Router Organization
Nested Routers
// server/routers/user.ts
export const userRouter = router({
list: publicProcedure.query(/* ... */),
byId: publicProcedure.input(z.string()).query(/* ... */),
create: protectedProcedure.input(createUserSchema).mutation(/* ... */),
update: protectedProcedure.input(updateUserSchema).mutation(/* ... */),
delete: adminProcedure.input(z.string()).mutation(/* ... */),
});
// server/routers/post.ts
export const postRouter = router({
list: publicProcedure.query(/* ... */),
bySlug: publicProcedure.input(z.string()).query(/* ... */),
create: protectedProcedure.input(createPostSchema).mutation(/* ... */),
});
Merge Routers
// server/root.ts
import { router } from './trpc';
import { userRouter } from './routers/user';
import { postRouter } from './routers/post';
import { commentRouter } from './routers/comment';
export const appRouter = router({
user: userRouter,
post: postRouter,
comment: commentRouter,
});
export type AppRouter = typeof appRouter;
Recommended File Structure
Framework Integration
| Framework | Adapter | Package |
|---|---|---|
| Next.js Pages Router | createNextApiHandler | @trpc/server/adapters/next |
| Next.js App Router | fetchRequestHandler | @trpc/server/adapters/fetch |
| Express | createExpressMiddleware | @trpc/server/adapters/express |
| Fastify | fastifyTRPCPlugin | @trpc/server/adapters/fastify |
| Standalone (Node) | createHTTPServer | @trpc/server/adapters/standalone |
Next.js Pages Router
// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '~/server/root';
import { createContext } from '~/server/context';
export default createNextApiHandler({
router: appRouter,
createContext,
onError: ({ error, path }) => {
if (error.code === 'INTERNAL_SERVER_ERROR') {
console.error(`tRPC error on ${path}:`, error);
}
},
});
Next.js App Router
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '~/server/root';
import { createContext } from '~/server/context';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
});
export { handler as GET, handler as POST };
Express Adapter
import express from 'express';
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import { appRouter } from './root';
import { createContext } from './context';
const app = express();
app.use(
'/api/trpc',
createExpressMiddleware({ router: appRouter, createContext })
);
app.listen(3000);
Fastify Adapter
import Fastify from 'fastify';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import { appRouter } from './root';
import { createContext } from './context';
const server = Fastify();
server.register(fastifyTRPCPlugin, {
prefix: '/api/trpc',
trpcOptions: { router: appRouter, createContext },
});
server.listen({ port: 3000 });
Error Handling
TRPCError Codes
| Code | HTTP Status | When to Use |
|---|---|---|
| BAD_REQUEST | 400 | Invalid input data |
| UNAUTHORIZED | 401 | Not logged in |
| FORBIDDEN | 403 | Insufficient permissions |
| NOT_FOUND | 404 | Resource does not exist |
| CONFLICT | 409 | Resource conflict (e.g. duplicate creation) |
| TOO_MANY_REQUESTS | 429 | Rate limit exceeded |
| INTERNAL_SERVER_ERROR | 500 | Unexpected server error |
| TIMEOUT | 408 | Request timeout |
Throwing TRPCError
import { TRPCError } from '@trpc/server';
getPost: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const post = await ctx.db.post.findUnique({ where: { id: input.id } });
if (!post) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Post with id "${input.id}" not found`,
});
}
return post;
}),
Custom Error Formatter
import { ZodError } from 'zod';
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
Client-Side Error Handling
// React Query style
const { data, error } = trpc.post.byId.useQuery({ id: '123' });
if (error) {
if (error.data?.code === 'NOT_FOUND') {
return <NotFound />;
}
// Zod validation errors
if (error.data?.zodError) {
const fieldErrors = error.data.zodError.fieldErrors;
// render field-level errors...
}
}
Advanced Features
Output Transformers (superjson)
Standard JSON does not support Date, Map, Set, and other complex types. Use superjson to automatically serialize and deserialize them.
import superjson from 'superjson';
// Server
const t = initTRPC.context<Context>().create({
transformer: superjson,
});
// Client must also use the same transformer
import { createTRPCClient, httpBatchLink } from '@trpc/client';
const client = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: '/api/trpc', transformer: superjson })],
});
Metadata on Procedures
type Meta = { requiresAuth: boolean; rateLimit?: number };
const t = initTRPC
.meta<Meta>()
.context<Context>()
.create();
// Attach metadata
publicProcedure
.meta({ requiresAuth: false, rateLimit: 100 })
.query(() => { /* ... */ });
// Read metadata in middleware
const rateLimiter = middleware(async ({ meta, next }) => {
if (meta?.rateLimit) {
// perform rate limiting logic...
}
return next();
});
Batch Requests
// Client uses httpBatchLink by default
// Multiple requests in the same tick are automatically batched
import { httpBatchLink } from '@trpc/client';
const client = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
maxURLLength: 2083, // GET request URL length limit
}),
],
});
Server-Side Caller
// Call procedures directly in Server Components or backend logic
const caller = appRouter.createCaller({
session: null,
db: prisma,
});
// Direct execution, no HTTP involved
const users = await caller.user.list({ limit: 10 });
const post = await caller.post.bySlug('hello-world');
Links (splitLink for Conditional Routing)
import { splitLink, httpBatchLink, httpLink } from '@trpc/client';
const client = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: wsLink({ url: 'ws://localhost:3001' }),
false: httpBatchLink({ url: '/api/trpc' }),
}),
],
});
FAQ
tRPC is ideal for TypeScript full-stack projects, providing zero-cost end-to-end type safety. REST is better for public APIs and multi-language clients. GraphQL is best for complex data graphs and scenarios requiring precise field selection. If both your frontend and backend use TypeScript, tRPC offers the best developer experience.
Absolutely. tRPC provides adapters for Express, Fastify, Standalone HTTP, Fetch API, and more. Any Node.js server framework can use tRPC, and the client can use vanilla JavaScript or alternatives to React Query.
tRPC does not natively support multipart/form-data. Recommended approaches: (1) Use a separate REST endpoint for uploads, return the URL, then save metadata via a tRPC mutation; (2) Use presigned URLs to upload directly to S3/R2, then record file info through tRPC.
Implement it in the middleware layer. You can use procedure metadata to tag rate limit configs, then read them in middleware and call a Redis-based or in-memory rate limiter. Alternatively, add rate limiting at the adapter level using packages like express-rate-limit.
Use appRouter.createCaller() to create a server-side caller with a mocked context (test database, fake session), then test each procedure like a regular function. No HTTP server needed.