Every full-stack TypeScript project hits the same problem: you define types on the server, then re-define them on the client. When the API contract changes, the compiler does not catch the mismatch. Runtime errors appear in production instead of red squiggles in your editor.
tRPC solves this by sharing types directly between your server and client through TypeScript inference. No code generation. No schema files. No runtime overhead for parsing query languages. You write a function on the server, and the client knows its input and output types automatically.
This guide walks through a complete tRPC setup with Next.js App Router, from installation to production deployment. Every code example is copy-pasteable and tested against tRPC v11.
Validate your TypeScript as you follow along. The TypeScript Playground lets you test type definitions and catch errors in your browser without any local setup.
What Is tRPC and Why It Exists
tRPC stands for TypeScript Remote Procedure Call. It is an open-source library that lets you build fully typed APIs where the server definition is the single source of truth for both input validation and response types.
The core idea is simple: you write a TypeScript function on the server. The client imports the function's type (not the function itself) and gets full autocompletion, type checking, and inference. If you change a field name on the server, the client breaks at compile time, not at runtime.
Here is what that looks like in practice:
server/routers/user.ts// Server: define a procedure
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
return user; // Return type is inferred
}),
});
app/components/UserProfile.tsx
// Client: types flow automatically
const { data: user } = trpc.user.getById.useQuery({ id: '123' });
// TypeScript knows: user is { id: string; name: string; email: string } | null
// Passing { id: 123 } would be a compile error (number instead of string)
The type travels from your database schema through Prisma, into tRPC, and out to your React component. No manual type definitions anywhere in between. This is what "end-to-end type safety" means in practice.
What tRPC is not
tRPC is not a replacement for REST or GraphQL in every scenario. It requires both client and server to be TypeScript. It does not generate API documentation for third-party consumers. It does not work across language boundaries. If you are building a public API that other teams or mobile apps consume, REST with OpenAPI or GraphQL is a better choice. tRPC is designed for the common case: a TypeScript frontend talking to a TypeScript backend within the same codebase.
tRPC vs REST vs GraphQL
Choosing between these three depends on your constraints. Here is an honest comparison.
REST
- Best for: Public APIs, multi-language clients, simple CRUD
- Type safety: Manual. You can add OpenAPI/Swagger, but types are not enforced at compile time
- Learning curve: Low. Every developer knows HTTP verbs and JSON
- Tooling: Mature. Postman, curl, browser dev tools all work out of the box
- Drawback: Overfetching and underfetching. No automatic type sharing
GraphQL
- Best for: Complex data graphs, multiple client platforms, teams that need a strict schema contract
- Type safety: Good with codegen (graphql-codegen). Requires a build step
- Learning curve: High. Schema definition language, resolvers, query language, client caching
- Tooling: GraphiQL, Apollo DevTools, Relay DevTools
- Drawback: Complexity overhead. Schema + codegen + runtime parser for what might be simple calls
tRPC
- Best for: Full-stack TypeScript monorepos, rapid prototyping, internal tools
- Type safety: Excellent. Zero codegen. Types are inferred automatically
- Learning curve: Low if you know TypeScript and React Query
- Tooling: TypeScript compiler is the tool. No extra DevTools needed
- Drawback: TypeScript-only. Not suitable for public APIs or non-TS clients
Quick decision. If your frontend and backend are both TypeScript in the same repo, start with tRPC. If you need to serve non-TypeScript clients or expose a public API, use REST with OpenAPI. If you have a complex data graph with many different client needs, consider GraphQL.
Setting Up tRPC with Next.js App Router
This setup uses Next.js 14+ with the App Router, tRPC v11, and Zod for input validation. Start by installing the required packages.
Terminalnpm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
The project structure follows a pattern that keeps tRPC configuration separate from your application logic.
Project structuresrc/
server/
trpc.ts # tRPC initialization + context
routers/
_app.ts # Root router (merges all sub-routers)
user.ts # User procedures
post.ts # Post procedures
app/
api/trpc/[trpc]/
route.ts # Next.js API route handler
_trpc/
client.ts # tRPC client for Client Components
server.ts # tRPC caller for Server Components
providers.tsx # React Query + tRPC provider
Step 1: Initialize tRPC on the server
Create the tRPC instance and define your context. The context is an object available in every procedure, typically containing the database client, session information, and any shared utilities.
src/server/trpc.tsimport { initTRPC, TRPCError } from '@trpc/server';
import { type FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
export const createTRPCContext = async (opts: FetchCreateContextFnOptions) => {
const session = await getSession(opts.req);
return {
db,
session,
};
};
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;
The superjson transformer handles Date objects, Map, Set, and other types that JSON.stringify cannot serialize. The error formatter exposes Zod validation errors in a structured format so the client can display field-level messages.
Step 2: Create the API route handler
src/app/api/trpc/[trpc]/route.tsimport { 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,
});
export { handler as GET, handler as POST };
This single route handler catches all tRPC requests. Next.js dynamic routes with [trpc] forward the procedure path to tRPC's internal router.
Creating Your First Router and Procedures
tRPC organizes your API into routers. Each router groups related procedures. Procedures come in three types:
- query: Read operations. Maps to HTTP GET. Cached by React Query
- mutation: Write operations. Maps to HTTP POST. Not cached
- subscription: WebSocket streams. Used for real-time data
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
export const postRouter = router({
// Query: fetch a list of posts
list: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().nullish(), // for cursor-based pagination
})
)
.query(async ({ ctx, input }) => {
const posts = await ctx.db.post.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
let nextCursor: string | undefined;
if (posts.length > input.limit) {
const nextItem = posts.pop();
nextCursor = nextItem?.id;
}
return { posts, nextCursor };
}),
// Query: fetch a single post by ID
byId: 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' });
return post;
}),
// Mutation: create a new post
create: publicProcedure
.input(
z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
published: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({ data: input });
}),
});
Merge routers into a single root router that represents your entire API.
src/server/routers/_app.tsimport { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
export const appRouter = router({
user: userRouter,
post: postRouter,
});
// Export type for the client
export type AppRouter = typeof appRouter;
The AppRouter type export is the bridge between server and client. The client imports this type (not the runtime code) and uses it to infer all procedure types.
Input Validation with Zod
Every tRPC procedure can define an .input() validator using Zod. When a client sends invalid data, tRPC returns a BAD_REQUEST error with structured field-level details before your procedure logic ever runs.
import { z } from 'zod';
// String with constraints
const createUserInput = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user', 'editor']),
age: z.number().int().positive().optional(),
tags: z.array(z.string()).max(10).default([]),
});
// Reuse schemas across procedures
const paginationInput = z.object({
page: z.number().int().positive().default(1),
perPage: z.number().int().min(1).max(100).default(20),
});
// Compose with .merge() or .extend()
const searchInput = paginationInput.extend({
query: z.string().min(1),
sortBy: z.enum(['relevance', 'date', 'title']).default('relevance'),
});
Zod schemas serve double duty: they validate the input at runtime and infer the TypeScript type at compile time. When you write .input(z.object({ id: z.string() })), both the runtime validator and the TypeScript type { id: string } come from the same source. No possibility of drift.
To validate and format your Zod schemas or convert JSON responses into TypeScript interfaces, the JSON to TypeScript Converter generates accurate type definitions from any JSON payload.
Zod strips unknown keys by default. Using .passthrough() or .strict() changes this behavior. In tRPC, the default stripping behavior is what you want. It prevents clients from sending unexpected fields that could cause security issues or database errors.
React Query Integration
tRPC's React integration is built on top of @tanstack/react-query. Every useQuery and useMutation call is type-safe with zero manual generics.
Setting up the client
src/app/_trpc/client.tsimport { 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 { useState } from 'react';
import { trpc } from './_trpc/client';
import superjson from 'superjson';
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
},
},
}));
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>
);
}
Using queries
app/posts/page.tsx (Client Component)'use client';
import { trpc } from '@/app/_trpc/client';
export default function PostsPage() {
// TypeScript infers the return type from the server procedure
const { data, isLoading, error } = trpc.post.list.useQuery({
limit: 20,
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<ul>
{data?.posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Using mutations
'use client';
import { trpc } from '@/app/_trpc/client';
export function CreatePostForm() {
const utils = trpc.useUtils();
const createPost = trpc.post.create.useMutation({
onSuccess: () => {
// Invalidate the post list cache so it refetches
utils.post.list.invalidate();
},
});
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createPost.mutate({
title: formData.get('title') as string,
content: formData.get('content') as string,
});
}
return (
<form onSubmit={handleSubmit}>
<input name="title" required />
<textarea name="content" required />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
{createPost.error && <p>{createPost.error.message}</p>}
</form>
);
}
Server-side calling (Server Components)
For Server Components, you bypass the HTTP layer entirely and call procedures directly.
src/app/_trpc/server.tsimport 'server-only';
import { createCallerFactory } from '@/server/trpc';
import { appRouter } from '@/server/routers/_app';
import { db } from '@/lib/db';
import { getSession } from '@/lib/auth';
import { headers } from 'next/headers';
const createCaller = createCallerFactory(appRouter);
export async function createServerCaller() {
const session = await getSession();
return createCaller({ db, session });
}
app/posts/[id]/page.tsx (Server Component)
import { createServerCaller } from '@/app/_trpc/server';
export default async function PostPage({ params }: { params: { id: string } }) {
const trpc = await createServerCaller();
const post = await trpc.post.byId({ id: params.id });
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
Authentication and Middleware
tRPC middleware runs before a procedure executes. The most common use case is authentication: verifying that the request has a valid session before running the procedure logic.
src/server/trpc.ts (add to existing file)const isAuthenticated = t.middleware(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in to perform this action',
});
}
return next({
ctx: {
// Override the context with a guaranteed non-null user
session: ctx.session,
user: ctx.session.user,
},
});
});
// Create a procedure that requires authentication
export const protectedProcedure = t.procedure.use(isAuthenticated);
Now any procedure built with protectedProcedure instead of publicProcedure automatically has ctx.user available and typed as non-null.
import { protectedProcedure } from '../trpc';
export const postRouter = router({
// Anyone can read posts
list: publicProcedure.query(/* ... */),
// Only authenticated users can create posts
create: protectedProcedure
.input(z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
// ctx.user is guaranteed to exist (TypeScript knows this)
return ctx.db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
});
Role-based middleware
You can chain middleware for more granular authorization.
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 = t.procedure
.use(isAuthenticated)
.use(isAdmin);
Logging middleware
const logger = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
if (result.ok) {
console.log(`[tRPC] ${type} ${path} - ${duration}ms`);
} else {
console.error(`[tRPC] ${type} ${path} - ${duration}ms - ERROR`);
}
return result;
});
For testing your API responses during development, the API Tester lets you send requests and inspect responses directly in the browser. And the JSON Formatter makes tRPC's JSON responses readable when debugging.
Error Handling Patterns
tRPC provides a TRPCError class with error codes that map to HTTP status codes. Throwing these errors from any procedure or middleware returns a structured error to the client.
import { TRPCError } from '@trpc/server';
export const postRouter = router({
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
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`,
});
}
if (post.authorId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only delete your own posts',
});
}
await ctx.db.post.delete({ where: { id: input.id } });
return { success: true };
}),
});
Error codes reference
BAD_REQUEST(400) - Invalid input. Automatically used for Zod validation failuresUNAUTHORIZED(401) - Not authenticatedFORBIDDEN(403) - Authenticated but not permittedNOT_FOUND(404) - Resource does not existCONFLICT(409) - Resource already exists or state conflictTOO_MANY_REQUESTS(429) - Rate limitedINTERNAL_SERVER_ERROR(500) - Unexpected server error
Client-side error handling
'use client';
import { TRPCClientError } from '@trpc/client';
function DeleteButton({ postId }: { postId: string }) {
const deletePost = trpc.post.delete.useMutation({
onError: (error) => {
if (error instanceof TRPCClientError) {
switch (error.data?.code) {
case 'NOT_FOUND':
alert('This post has already been deleted.');
break;
case 'FORBIDDEN':
alert('You do not have permission to delete this post.');
break;
default:
alert(`Error: ${error.message}`);
}
}
},
onSuccess: () => {
// Redirect or update UI
},
});
return (
<button onClick={() => deletePost.mutate({ id: postId })}>
Delete
</button>
);
}
Handling Zod validation errors on the client
When a Zod validation fails, tRPC includes the structured error in the response. You can use this to display field-level error messages.
const createPost = trpc.post.create.useMutation({
onError: (error) => {
// Access Zod field errors from the error formatter we set up earlier
const zodErrors = error.data?.zodError?.fieldErrors;
if (zodErrors) {
// zodErrors is { title?: string[], content?: string[] }
Object.entries(zodErrors).forEach(([field, messages]) => {
console.log(`${field}: ${messages?.join(', ')}`);
});
}
},
});
Testing tRPC Procedures
One of tRPC's best features is testability. Procedures are regular async functions. You test them by creating a caller with a mock context.
__tests__/post.test.tsimport { describe, it, expect, beforeEach } from 'vitest';
import { createCallerFactory } from '@/server/trpc';
import { appRouter } from '@/server/routers/_app';
import { db } from '@/lib/db'; // Your test database
const createCaller = createCallerFactory(appRouter);
describe('post router', () => {
let caller: ReturnType<typeof createCaller>;
beforeEach(() => {
// Create a caller with a mock context
caller = createCaller({
db,
session: {
user: { id: 'test-user-1', name: 'Test', role: 'user' },
expires: new Date(Date.now() + 86400000).toISOString(),
},
});
});
it('creates a post', async () => {
const post = await caller.post.create({
title: 'Test Post',
content: 'This is a test post.',
});
expect(post.title).toBe('Test Post');
expect(post.authorId).toBe('test-user-1');
});
it('returns NOT_FOUND for missing post', async () => {
await expect(
caller.post.byId({ id: 'nonexistent-id' })
).rejects.toThrow('NOT_FOUND');
});
it('prevents deleting another user\'s post', async () => {
// Create a post as a different user
const otherCaller = createCaller({
db,
session: {
user: { id: 'other-user', name: 'Other', role: 'user' },
expires: new Date(Date.now() + 86400000).toISOString(),
},
});
const post = await otherCaller.post.create({
title: 'Their Post',
content: 'Content',
});
// Try to delete as test-user-1
await expect(
caller.post.delete({ id: post.id })
).rejects.toThrow('FORBIDDEN');
});
});
This pattern tests your actual business logic, including middleware and validation, without starting an HTTP server. The test runs in milliseconds.
Use a separate test database (SQLite in-memory or a Dockerized Postgres) and reset it between tests. Prisma's $transaction with rollback is another pattern for fast, isolated tests.
Production Tips and Performance
Request batching
tRPC batches multiple procedure calls from the same render cycle into a single HTTP request by default. If a page calls trpc.user.me.useQuery() and trpc.post.list.useQuery(), both resolve in one network round-trip.
This is enabled automatically with httpBatchLink. If you need to disable it for specific procedures (like file uploads), use httpLink for those calls.
Prefetching and SSR
For Server-Side Rendering, prefetch data on the server and pass it to the client to avoid a loading flash.
// In a Server Component
import { createServerCaller } from '@/app/_trpc/server';
import { HydrateClient } from '@/app/_trpc/client';
export default async function PostsPage() {
const trpc = await createServerCaller();
// Prefetch on the server
const posts = await trpc.post.list({ limit: 20 });
// Pass to client as props or use React Server Components directly
return <PostsList initialData={posts} />;
}
Response caching
Add HTTP cache headers in your procedures for CDN caching.
export const postRouter = router({
list: publicProcedure
.input(paginationInput)
.query(async ({ ctx, input }) => {
// Set cache headers (only works with HTTP transport)
ctx.resHeaders?.set(
'Cache-Control',
'public, s-maxage=60, stale-while-revalidate=300'
);
return ctx.db.post.findMany({
take: input.perPage,
skip: (input.page - 1) * input.perPage,
});
}),
});
Monitoring and observability
Add a global middleware that logs slow queries and errors to your monitoring service.
const observability = t.middleware(async ({ path, type, next }) => {
const start = performance.now();
const result = await next();
const duration = performance.now() - start;
// Log slow procedures (over 1 second)
if (duration > 1000) {
console.warn(`[SLOW] ${type} ${path} took ${duration.toFixed(0)}ms`);
}
// Send to your monitoring (Sentry, Datadog, etc.)
if (!result.ok) {
captureException(result.error, {
tags: { trpc_path: path, trpc_type: type },
});
}
return result;
});
Bundle size
tRPC adds minimal client-side JavaScript. The @trpc/client package is roughly 5KB gzipped. The main dependency is @tanstack/react-query at approximately 13KB gzipped. Together they are smaller than most GraphQL clients.
Debug your API payloads. The JSON Formatter makes complex tRPC responses readable, and the JSON Path Finder helps you navigate deeply nested response objects.
Environment variables
Keep your tRPC endpoint URL configurable across environments. Use the Env File Editor to manage environment variables across development, staging, and production without manual editing mistakes.
// In your tRPC client setup
httpBatchLink({
url: `${process.env.NEXT_PUBLIC_APP_URL}/api/trpc`,
transformer: superjson,
})
Related Developer Tools
Free browser-based tools to complement your tRPC and TypeScript API development.
Frequently Asked Questions
tRPC is a TypeScript-first framework for building APIs where the server and client share types directly through TypeScript inference, without code generation or schema files. Unlike REST, where you manually define request and response types on both sides, tRPC infers them automatically from your server code. Unlike GraphQL, which requires a schema definition language, a code generator, and a runtime query parser, tRPC uses plain TypeScript functions. The tradeoff is that tRPC only works when both your client and server are written in TypeScript within the same monorepo or shared package setup. If you need to serve mobile apps, third-party consumers, or non-TypeScript clients, REST with OpenAPI or GraphQL is a better fit.
Yes. tRPC works with Next.js App Router through two patterns. For Server Components, you create a server-side caller that invokes tRPC procedures directly without an HTTP round-trip. For Client Components, you use the standard React Query integration with a tRPC client that points to a Next.js API route handler. The API route handler is set up at app/api/trpc/[trpc]/route.ts using the fetchRequestHandler from @trpc/server/adapters/fetch. This setup gives you type-safe data fetching in both server and client contexts within the same Next.js application.
No, tRPC builds on top of React Query. When you use @trpc/react-query, every tRPC procedure call is backed by React Query under the hood. You get all of React Query's features including caching, background refetching, optimistic updates, infinite queries, and stale-while-revalidate behavior. tRPC adds automatic type inference on top of that so your query keys, input parameters, and return types are all type-safe without any manual generics. You can also access the underlying React Query client directly for advanced patterns like cache manipulation or prefetching.
tRPC provides a TRPCError class for throwing typed errors from procedures. Each error includes a code (like NOT_FOUND, UNAUTHORIZED, BAD_REQUEST, or INTERNAL_SERVER_ERROR) that maps to an HTTP status code. On the client, failed procedures throw a TRPCClientError that includes the error code, message, and any additional data you attached. For Zod validation errors, tRPC automatically returns a BAD_REQUEST with structured field-level error details. You can also set up a global error formatter on the server to customize the error shape, add logging, or strip sensitive information before errors reach the client.
Yes. tRPC is production-ready and has been used in production by companies including Cal.com, Ping.gg, and many startups built on the T3 Stack (Next.js + tRPC + Prisma + Tailwind). The library has over 35,000 GitHub stars and a stable v11 release. It is maintained by a dedicated team and has a well-documented migration path between major versions. tRPC is particularly popular in full-stack TypeScript applications where both the frontend and backend share a single repository, which is the most common architecture for Next.js projects.