TL;DR
tRPC lets you build fully type-safe APIs without writing API schemas, code generation, or REST/GraphQL boilerplate. You define server procedures and call them from your client with full autocompletion and type checking. Changes to your API are caught by TypeScript at compile time, not by your users in production. It integrates natively with Zod for validation and React Query for data fetching.
Key Takeaways
- tRPC provides end-to-end type safety from server to client without schemas or code generation
- Built on top of React Query, giving you caching, deduplication, and background refetching for free
- Zod integration makes input validation type-safe and declarative
- Middleware and context enable authentication, authorization, and request-scoped data
- Works with Next.js App Router, Express, Fastify, and other frameworks
- Subscriptions via WebSockets for real-time features
What Is tRPC?
tRPC (TypeScript Remote Procedure Call) is a library that enables you to build type-safe APIs for TypeScript applications. Unlike REST or GraphQL, tRPC requires no API schema definition, no code generation step, and no runtime overhead for type checking. Your server-side procedure definitions become the single source of truth, and TypeScript ensures your client calls match exactly.
With tRPC, when you rename a field on the server, TypeScript immediately flags every client usage that needs updating. When you add a required input parameter, the client code fails to compile until it provides it. This eliminates an entire class of bugs that traditionally plague full-stack development.
// Traditional REST workflow:
// 1. Define REST routes on server
// 2. Write fetch calls on client
// 3. Manually type the response
// 4. Hope they stay in sync
// tRPC workflow:
// 1. Define a procedure on the server
const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
return db.user.findUnique({ where: { id: input.id } });
}),
});
// 2. Call it from the client β fully typed!
const { data } = trpc.getUser.useQuery({ id: "123" });
// data is automatically typed as User | null
// TypeScript error if you pass wrong input shapeSetting Up tRPC with Next.js
The most common tRPC setup uses Next.js with the App Router. Here is how to install and configure everything from scratch.
Installation
npm install @trpc/server @trpc/client @trpc/react-query \
@trpc/next @tanstack/react-query zod superjsonInitialize the tRPC Server
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
export const createTRPCContext = async (opts: {
headers: Headers;
}) => {
const session = await getServerSession();
return {
db: prisma,
session,
headers: opts.headers,
};
};
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 createCallerFactory = t.createCallerFactory;Create the API Route Handler (Next.js App Router)
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createTRPCContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () =>
createTRPCContext({ headers: req.headers }),
});
export { handler as GET, handler as POST };Set Up the Client Provider
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();
// src/app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { trpc } from "@/lib/trpc";
import superjson from "superjson";
import { useState } from "react";
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: { staleTime: 5 * 60 * 1000 },
},
}));
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: "/api/trpc",
transformer: superjson,
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}Setting Up tRPC with Express
tRPC also works as a standalone Express middleware, which is useful if you are not using Next.js.
// server.ts
import express from "express";
import * as trpcExpress from "@trpc/server/adapters/express";
import { appRouter } from "./routers/_app";
import { createTRPCContext } from "./trpc";
const app = express();
app.use(
"/api/trpc",
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext: ({ req, res }) => ({
db: prisma,
session: req.session,
}),
})
);
app.listen(3000, () => {
console.log("Server running on port 3000");
});Routers and Procedures
Routers organize your API into logical groups. Procedures are the individual endpoints within a router. There are three types of procedures: queries (read data), mutations (write data), and subscriptions (real-time streams).
// src/server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
export const userRouter = router({
// Query: fetch data (GET-like)
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.db.user.findUnique({
where: { id: input.id },
});
}),
// Query: list with pagination
list: publicProcedure
.input(z.object({
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(100).default(20),
}))
.query(async ({ ctx, input }) => {
const { page, limit } = input;
const [users, total] = await Promise.all([
ctx.db.user.findMany({
skip: (page - 1) * limit,
take: limit,
}),
ctx.db.user.count(),
]);
return { users, total, pages: Math.ceil(total / limit) };
}),
// Mutation: write data (POST/PUT/DELETE-like)
update: protectedProcedure
.input(z.object({
id: z.string(),
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
}))
.mutation(async ({ ctx, input }) => {
return ctx.db.user.update({
where: { id: input.id },
data: input,
});
}),
});Nested Routers
You can nest routers to create a hierarchical API structure. This keeps your code organized as your API grows.
// src/server/routers/_app.ts
import { router } from "../trpc";
import { userRouter } from "./user";
import { postRouter } from "./post";
import { commentRouter } from "./comment";
export const appRouter = router({
user: userRouter,
post: postRouter,
comment: commentRouter,
});
// Export the type for the client
export type AppRouter = typeof appRouter;
// Client usage:
// trpc.user.getById.useQuery({ id: "123" })
// trpc.post.list.useQuery({ page: 1 })
// trpc.comment.create.useMutation()Input Validation with Zod
tRPC integrates with Zod for input validation. When you define an input schema, tRPC validates incoming data at runtime and infers the TypeScript type for your procedure handler automatically.
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
export const productRouter = router({
create: publicProcedure
.input(
z.object({
name: z.string().min(1, "Name is required").max(200),
price: z.number().positive("Price must be positive"),
category: z.enum(["electronics", "clothing", "food"]),
tags: z.array(z.string()).max(10).default([]),
metadata: z.record(z.string(), z.unknown()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
// input is fully typed:
// {
// name: string;
// price: number;
// category: "electronics" | "clothing" | "food";
// tags: string[];
// metadata?: Record<string, unknown>;
// }
return ctx.db.product.create({ data: input });
}),
});Complex Input Schemas
// Reusable input schemas
const PaginationInput = z.object({
cursor: z.string().nullish(),
limit: z.number().int().min(1).max(100).default(20),
});
const DateRangeInput = z.object({
from: z.date(),
to: z.date(),
}).refine(
(data) => data.from <= data.to,
{ message: "Start date must be before end date" }
);
const SearchInput = z.object({
query: z.string().min(1).max(500),
filters: z.object({
category: z.enum(["all", "posts", "users"]).default("all"),
dateRange: DateRangeInput.optional(),
sortBy: z.enum(["relevance", "date", "popularity"]).default("relevance"),
}).default({}),
pagination: PaginationInput.default({}),
});
export const searchRouter = router({
search: publicProcedure
.input(SearchInput)
.query(async ({ ctx, input }) => {
// All fields are validated and typed
const { query, filters, pagination } = input;
return ctx.db.search(query, filters, pagination);
}),
});Queries and Mutations
Queries fetch data and are called with useQuery on the client. Mutations modify data and are called with useMutation. Both are fully type-safe.
Calling from the Client
// Using queries in React components
function UserProfile({ userId }: { userId: string }) {
// Fully typed query β data type is inferred from server
const { data: user, isLoading, error } = trpc.user.getById.useQuery(
{ id: userId },
{ enabled: !!userId }
);
// Fully typed mutation
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
// Invalidate and refetch the query after mutation
utils.user.getById.invalidate({ id: userId });
},
onError: (err) => {
// err.data?.zodError has field-level errors
console.error("Update failed:", err.message);
},
});
const utils = trpc.useUtils();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<form onSubmit={(e) => {
e.preventDefault();
updateUser.mutate({
id: userId,
name: "New Name", // TypeScript ensures valid fields
});
}}>
<p>{user.name}</p>
<p>{user.email}</p>
<button type="submit" disabled={updateUser.isPending}>
{updateUser.isPending ? "Saving..." : "Save"}
</button>
</form>
);
}Middleware
Middleware lets you run shared logic before your procedures execute. Common uses include authentication checks, logging, rate limiting, and timing. Middleware can modify the context that gets passed to the procedure.
Authentication Middleware
// src/server/trpc.ts
const isAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in",
});
}
// Add user to context for downstream procedures
return next({
ctx: {
session: ctx.session,
user: ctx.session.user, // Now guaranteed to exist
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);
// Logging middleware
const logger = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
console.log(
`[tRPC] \${type} \${path} - \${duration}ms - ` +
`\${result.ok ? "OK" : "ERROR"}`
);
return result;
});
// Role-based middleware
const isAdmin = t.middleware(async ({ ctx, next }) => {
if (ctx.session?.user?.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required",
});
}
return next({ ctx });
});
export const adminProcedure = protectedProcedure.use(isAdmin);Context
Context is data that is available to all procedures and middleware. It is created per-request and typically includes the database connection, the authenticated user, and other request-scoped state.
// src/server/context.ts
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
export const createTRPCContext = async (opts: {
headers: Headers;
}) => {
const session = await getServerSession(authOptions);
return {
// Database client β shared across all procedures
db: prisma,
// Authenticated session (null if not logged in)
session,
// Request headers for IP, user-agent, etc.
headers: opts.headers,
// Request ID for tracing
requestId: crypto.randomUUID(),
};
};
export type Context = Awaited<ReturnType<typeof createTRPCContext>>;
// Procedures access context via ctx:
// .query(async ({ ctx }) => {
// ctx.db β Prisma client
// ctx.session β Auth session
// ctx.headers β Request headers
// })Error Handling
tRPC provides a TRPCError class for throwing structured errors with HTTP status codes. These errors are serialized and sent to the client where they can be caught and handled.
import { TRPCError } from "@trpc/server";
export const postRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const post = await ctx.db.post.findUnique({
where: { id: input.id },
});
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
});
}
// Check permission
if (post.isPrivate && post.authorId !== ctx.session?.user?.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to view this post",
});
}
return post;
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
try {
return await ctx.db.post.delete({
where: { id: input.id, authorId: ctx.user.id },
});
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete post",
cause: e,
});
}
}),
});
// Available error codes:
// PARSE_ERROR 400
// BAD_REQUEST 400
// UNAUTHORIZED 401
// FORBIDDEN 403
// NOT_FOUND 404
// METHOD_NOT_SUPPORTED 405
// TIMEOUT 408
// CONFLICT 409
// TOO_MANY_REQUESTS 429
// INTERNAL_SERVER_ERROR 500Custom Error Formatting
// Custom error formatting in initTRPC
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
// Include Zod validation errors
zodError:
error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
// Client-side error handling
const mutation = trpc.user.update.useMutation({
onError: (error) => {
if (error.data?.zodError) {
// Field-level validation errors
const fieldErrors = error.data.zodError.fieldErrors;
// { name: ["Too short"], email: ["Invalid email"] }
} else {
// General server error
toast.error(error.message);
}
},
});React Query Integration
tRPC wraps React Query (TanStack Query) to provide type-safe data fetching hooks. You get all React Query features like caching, background refetching, optimistic updates, and infinite queries with full type inference.
Optimistic Updates
function TodoList() {
const utils = trpc.useUtils();
const todos = trpc.todo.list.useQuery();
const toggleTodo = trpc.todo.toggle.useMutation({
// Optimistically update the UI before server responds
onMutate: async (input) => {
// Cancel any outgoing refetches
await utils.todo.list.cancel();
// Snapshot the previous value
const previousTodos = utils.todo.list.getData();
// Optimistically update the cache
utils.todo.list.setData(undefined, (old) =>
old?.map((t) =>
t.id === input.id
? { ...t, completed: !t.completed }
: t
)
);
return { previousTodos };
},
// If mutation fails, roll back to snapshot
onError: (err, input, context) => {
utils.todo.list.setData(undefined, context?.previousTodos);
},
// Always refetch after error or success
onSettled: () => {
utils.todo.list.invalidate();
},
});
return (
<ul>
{todos.data?.map((todo) => (
<li key={todo.id} onClick={() => toggleTodo.mutate({ id: todo.id })}>
{todo.completed ? "[x]" : "[ ]"} {todo.text}
</li>
))}
</ul>
);
}Infinite Queries
// Server: cursor-based pagination
export const feedRouter = router({
infiniteFeed: publicProcedure
.input(z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).max(50).default(20),
}))
.query(async ({ ctx, input }) => {
const items = await ctx.db.post.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: "desc" },
});
let nextCursor: string | undefined;
if (items.length > input.limit) {
const nextItem = items.pop();
nextCursor = nextItem?.id;
}
return { items, nextCursor };
}),
});
// Client: infinite scroll
function Feed() {
const feed = trpc.feed.infiniteFeed.useInfiniteQuery(
{ limit: 20 },
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
);
return (
<div>
{feed.data?.pages.map((page) =>
page.items.map((item) => (
<PostCard key={item.id} post={item} />
))
)}
<button
onClick={() => feed.fetchNextPage()}
disabled={!feed.hasNextPage || feed.isFetchingNextPage}
>
{feed.isFetchingNextPage ? "Loading..." : "Load more"}
</button>
</div>
);
}Subscriptions (Real-Time)
Subscriptions use WebSockets to push data from the server to the client in real time. They are useful for chat applications, live dashboards, notifications, and collaborative editing.
// Server: WebSocket subscription
import { observable } from "@trpc/server/observable";
import { EventEmitter } from "events";
const ee = new EventEmitter();
export const chatRouter = router({
sendMessage: protectedProcedure
.input(z.object({
roomId: z.string(),
text: z.string().min(1).max(1000),
}))
.mutation(async ({ ctx, input }) => {
const message = {
id: crypto.randomUUID(),
text: input.text,
roomId: input.roomId,
userId: ctx.user.id,
createdAt: new Date(),
};
await ctx.db.message.create({ data: message });
ee.emit("newMessage", message);
return message;
}),
onNewMessage: publicProcedure
.input(z.object({ roomId: z.string() }))
.subscription(({ input }) => {
return observable((emit) => {
const handler = (message: Message) => {
if (message.roomId === input.roomId) {
emit.next(message);
}
};
ee.on("newMessage", handler);
return () => ee.off("newMessage", handler);
});
}),
});
// Client: subscribe to messages
function ChatRoom({ roomId }: { roomId: string }) {
trpc.chat.onNewMessage.useSubscription(
{ roomId },
{
onData: (message) => {
// Append new message to local state
setMessages((prev) => [...prev, message]);
},
onError: (err) => {
console.error("Subscription error:", err);
},
}
);
}Testing tRPC Procedures
You can test tRPC procedures directly by calling the router without an HTTP layer. This makes unit tests fast and simple.
// __tests__/user.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { appRouter } from "@/server/routers/_app";
import { createCallerFactory } from "@/server/trpc";
import { prisma } from "@/lib/__mocks__/prisma";
const createCaller = createCallerFactory(appRouter);
describe("user router", () => {
it("should return a user by ID", async () => {
const caller = createCaller({
db: prisma,
session: null,
headers: new Headers(),
});
prisma.user.findUnique.mockResolvedValue({
id: "1",
name: "Alice",
email: "alice@example.com",
});
const user = await caller.user.getById({ id: "1" });
expect(user).toEqual({
id: "1",
name: "Alice",
email: "alice@example.com",
});
});
it("should throw NOT_FOUND for missing user", async () => {
const caller = createCaller({
db: prisma,
session: null,
headers: new Headers(),
});
prisma.user.findUnique.mockResolvedValue(null);
await expect(
caller.user.getById({ id: "nonexistent" })
).rejects.toThrow("NOT_FOUND");
});
it("should reject invalid input", async () => {
const caller = createCaller({
db: prisma,
session: { user: { id: "1", role: "user" } },
headers: new Headers(),
});
await expect(
caller.user.update({ id: "1", email: "not-an-email" })
).rejects.toThrow();
});
it("should require auth for protected routes", async () => {
const caller = createCaller({
db: prisma,
session: null, // Not logged in
headers: new Headers(),
});
await expect(
caller.user.update({ id: "1", name: "Hacker" })
).rejects.toThrow("UNAUTHORIZED");
});
});Performance Tips
tRPC is already fast because it skips schema parsing and code generation, but there are additional optimizations you can apply.
- Use HTTP batching: tRPC batches multiple requests into a single HTTP call by default, reducing network overhead
- Enable transformer: Use superjson to serialize Dates, Maps, Sets, and other non-JSON types without manual conversion
- Leverage React Query caching: Configure staleTime and gcTime to avoid unnecessary refetches
- Use server-side calls: In Next.js Server Components, call procedures directly without HTTP overhead using createCallerFactory
- Split routers: Use dynamic imports for large routers to reduce bundle size
- Connection pooling: Reuse database connections in context creation instead of opening new ones per request
// Server-side calls in Next.js Server Components
// (no HTTP overhead β direct function call)
import { createCallerFactory } from "@/server/trpc";
import { appRouter } from "@/server/routers/_app";
const createCaller = createCallerFactory(appRouter);
export default async function UserPage({
params,
}: {
params: { id: string };
}) {
const caller = createCaller(await createTRPCContext({
headers: headers(),
}));
// Direct procedure call β no HTTP request
const user = await caller.user.getById({ id: params.id });
return <UserProfileView user={user} />;
}Frequently Asked Questions
How does tRPC differ from REST and GraphQL?
REST requires manually maintaining API contracts and has no built-in type safety. GraphQL provides a schema but requires code generation to get TypeScript types. tRPC skips both the schema definition and code generation by using TypeScript inference directly. Your server code IS your API contract. The trade-off is that tRPC only works with TypeScript clients, while REST and GraphQL are language-agnostic.
Can I use tRPC with a non-TypeScript client (mobile app, Python)?
tRPC is designed for TypeScript-to-TypeScript communication. For non-TypeScript clients, you can expose a REST or GraphQL API alongside tRPC, or use trpc-openapi to generate an OpenAPI spec from your tRPC router that any HTTP client can consume.
Does tRPC replace React Query?
No, tRPC is built on top of React Query (TanStack Query). It uses React Query under the hood for caching, background refetching, optimistic updates, and all other React Query features. tRPC adds type-safe procedure calls on top of that foundation.
How do I handle file uploads with tRPC?
tRPC does not natively handle multipart form data for file uploads. You can handle file uploads through a separate REST endpoint or use a presigned URL approach where tRPC returns a signed upload URL and the client uploads directly to cloud storage (S3, R2, etc).
Can I use tRPC without Next.js?
Yes. tRPC has adapters for Express, Fastify, standalone Node.js HTTP server, AWS Lambda, Cloudflare Workers, and more. Next.js is the most common setup but not required. You can use any Node.js server framework.
Is tRPC production-ready?
Yes. tRPC v11 is stable and used in production by companies of all sizes. It has been battle-tested since 2021 and is maintained actively by the core team and community contributors.
How do I version my tRPC API?
Since tRPC is typically used within a single TypeScript monorepo, versioning is handled by your deployment process. If you need versioning for external consumers, use trpc-openapi to expose versioned REST endpoints alongside your tRPC procedures.
Does tRPC support server-side rendering?
Yes. In Next.js, you can prefetch tRPC queries on the server using the createCaller API or React Server Components. The data is serialized to the client where React Query hydrates its cache, avoiding duplicate fetches on mount.