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

src/ server/ trpc.ts # initTRPC, middleware, procedure exports context.ts # createContext function root.ts # appRouter, AppRouter type routers/ user.ts # User-related procedures post.ts # Post-related procedures comment.ts # Comment-related procedures admin.ts # Admin procedures utils/ trpc.ts # Client-side trpc hooks (createTRPCReact)

Framework Integration

FrameworkAdapterPackage
Next.js Pages RoutercreateNextApiHandler@trpc/server/adapters/next
Next.js App RouterfetchRequestHandler@trpc/server/adapters/fetch
ExpresscreateExpressMiddleware@trpc/server/adapters/express
FastifyfastifyTRPCPlugin@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

CodeHTTP StatusWhen to Use
BAD_REQUEST400Invalid input data
UNAUTHORIZED401Not logged in
FORBIDDEN403Insufficient permissions
NOT_FOUND404Resource does not exist
CONFLICT409Resource conflict (e.g. duplicate creation)
TOO_MANY_REQUESTS429Rate limit exceeded
INTERNAL_SERVER_ERROR500Unexpected server error
TIMEOUT408Request 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 vs REST vs GraphQL?

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.

Can I use tRPC without Next.js?

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.

How to handle file uploads?

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.

How to add rate limiting?

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.

How to test tRPC procedures?

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.

Code Quick Reference