I'll never forget the day our API contract broke in production. A backend developer changed a field from userId to user_id. Our frontend TypeScript types said userId. No errors during development. Tests passed. But in production? 500,000 users saw blank screens. We needed code generation pipelines, OpenAPI specs, and constant synchronization between teams. Then I discovered tRPC. Same app, refactored in 3 days. Zero API contracts. Zero code generation. If the backend compiles, the frontend is guaranteed to work. That's the power of end-to-end type safety.
tRPC (TypeScript Remote Procedure Call) gives you GraphQL-level type safety with REST-like simplicity. No schema files, no code generation, no runtime overhead. Just TypeScript's type system doing what it does best: catching errors at compile time. Let's build production-ready tRPC APIs with Next.js 15.
Why tRPC?
The Problem: API Type Mismatches
Traditional REST API:
// Backend (Express)
app.get('/api/users/:id', (req, res) => {
const user = await db.users.findUnique({ where: { id: req.params.id } });
res.json(user);
});
// Frontend (separate types)
interface User {
id: string;
name: string;
email: string; // What if backend changes this?
}
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json(); // Runtime type - no safety!
}
Problem: Frontend and backend types drift. No compile-time safety.
With tRPC:
// Backend
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.users.findUnique({ where: { id: input.id } });
}),
});
// Frontend - types automatically inferred!
const user = await trpc.getUser.query({ id: '123' });
// ^? { id: string; name: string; email: string }
// TypeScript KNOWS the exact return type. Change backend = instant frontend error.
Result: If it compiles, the API contract is correct. Zero runtime surprises.
Installation & Setup
Step 1: Install Dependencies
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next
npm install @tanstack/react-query zod
Step 2: Create tRPC Router
// lib/trpc/server.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
// Initialize tRPC
const t = initTRPC.create();
// Export reusable router and procedure helpers
export const router = t.router;
export const publicProcedure = t.procedure;
Step 3: Define Your API
// lib/trpc/routers/_app.ts
import { router, publicProcedure } from '../server';
import { z } from 'zod';
export const appRouter = router({
// Query example
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
// Fetch from database
const user = await db.user.findUnique({
where: { id: input.id },
});
if (!user) throw new Error('User not found');
return user;
}),
// Mutation example
createUser: publicProcedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return await db.user.create({
data: input,
});
}),
});
// Export type definition
export type AppRouter = typeof appRouter;
Step 4: Create API Route (Next.js 15 App Router)
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/lib/trpc/routers/_app';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({}), // We'll add auth context later
});
export { handler as GET, handler as POST };
Step 5: Create Client
// lib/trpc/client.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from './routers/_app';
export const trpc = createTRPCReact<AppRouter>();
Step 6: Setup Provider
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/lib/trpc/client';
import { useState } from 'react';
function getBaseUrl() {
if (typeof window !== 'undefined') return ''; // Browser
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // Vercel
return `http://localhost:${process.env.PORT ?? 3000}`; // Dev
}
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
Step 7: Use in Components
// app/users/[id]/page.tsx
'use client';
import { trpc } from '@/lib/trpc/client';
export default function UserPage({ params }: { params: { id: string } }) {
const { data: user, isLoading } = trpc.getUser.useQuery({ id: params.id });
if (isLoading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
Core Concepts
Queries vs Mutations
export const appRouter = router({
// QUERY: Read operations (GET)
getUsers: publicProcedure
.query(async () => {
return await db.user.findMany();
}),
// MUTATION: Write operations (POST/PUT/DELETE)
createUser: publicProcedure
.input(z.object({ name: z.string(), email: z.string() }))
.mutation(async ({ input }) => {
return await db.user.create({ data: input });
}),
updateUser: publicProcedure
.input(z.object({ id: z.string(), name: z.string() }))
.mutation(async ({ input }) => {
return await db.user.update({
where: { id: input.id },
data: { name: input.name },
});
}),
deleteUser: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
return await db.user.delete({ where: { id: input.id } });
}),
});
Input Validation with Zod
export const appRouter = router({
createPost: publicProcedure
.input(z.object({
title: z.string().min(1).max(100),
content: z.string().min(10),
tags: z.array(z.string()).max(5),
published: z.boolean().default(false),
publishedAt: z.date().optional(),
}))
.mutation(async ({ input }) => {
// input is fully typed and validated!
return await db.post.create({ data: input });
}),
});
// Frontend usage - TypeScript enforces correct shape
const mutation = trpc.createPost.useMutation();
mutation.mutate({
title: 'Hello World',
content: 'This is my first post',
tags: ['typescript', 'trpc'],
published: true,
publishedAt: new Date(),
// TypeScript error if missing required fields or wrong types!
});
Nested Routers
// lib/trpc/routers/users.ts
export const usersRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.user.findUnique({ where: { id: input.id } });
}),
list: publicProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().optional(),
}))
.query(async ({ input }) => {
return await db.user.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
});
}),
create: publicProcedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ input }) => {
return await db.user.create({ data: input });
}),
});
// lib/trpc/routers/posts.ts
export const postsRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.post.findUnique({ where: { id: input.id } });
}),
create: publicProcedure
.input(z.object({ title: z.string(), content: z.string() }))
.mutation(async ({ input }) => {
return await db.post.create({ data: input });
}),
});
// lib/trpc/routers/_app.ts
export const appRouter = router({
users: usersRouter,
posts: postsRouter,
});
// Frontend usage
trpc.users.getById.useQuery({ id: '123' });
trpc.posts.create.useMutation();
Authentication & Context
Create Context with Auth
// lib/trpc/context.ts
import { NextRequest } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
export async function createContext(req: NextRequest) {
const session = await getServerSession(authOptions);
return {
session,
db, // Prisma client
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;
Update tRPC Config
// lib/trpc/server.ts
import { initTRPC, TRPCError } from '@trpc/server';
import type { Context } from './context';
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
// Protected procedure requires authentication
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
...ctx,
session: ctx.session, // Now session is guaranteed to exist
},
});
});
Use Protected Procedures
export const appRouter = router({
// Public - anyone can access
getPosts: publicProcedure
.query(async ({ ctx }) => {
return await ctx.db.post.findMany({ where: { published: true } });
}),
// Protected - requires authentication
createPost: protectedProcedure
.input(z.object({ title: z.string(), content: z.string() }))
.mutation(async ({ ctx, input }) => {
// ctx.session.user is guaranteed to exist here
return await ctx.db.post.create({
data: {
...input,
authorId: ctx.session.user.id,
},
});
}),
// Admin only
deleteAnyPost: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
if (ctx.session.user.role !== 'ADMIN') {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return await ctx.db.post.delete({ where: { id: input.id } });
}),
});
Update API Route
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/lib/trpc/routers/_app';
import { createContext } from '@/lib/trpc/context';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createContext(req), // Pass request to context
});
export { handler as GET, handler as POST };
Advanced Patterns
Pattern 1: Middleware for Logging
// lib/trpc/server.ts
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
console.log(`${type} ${path} - ${duration}ms`);
return result;
});
export const loggedProcedure = t.procedure.use(loggerMiddleware);
// Usage
export const appRouter = router({
getUser: loggedProcedure // Automatically logged
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.user.findUnique({ where: { id: input.id } });
}),
});
Pattern 2: Rate Limiting
import { TRPCError } from '@trpc/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'),
});
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
const identifier = ctx.session?.user?.id ?? 'anonymous';
const { success } = await ratelimit.limit(identifier);
if (!success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded',
});
}
return next();
});
export const rateLimitedProcedure = t.procedure.use(rateLimitMiddleware);
Pattern 3: Optimistic Updates
'use client';
import { trpc } from '@/lib/trpc/client';
export function TodoList() {
const utils = trpc.useUtils();
const { data: todos } = trpc.todos.list.useQuery();
const createMutation = trpc.todos.create.useMutation({
// Optimistic update
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await utils.todos.list.cancel();
// Snapshot current value
const previousTodos = utils.todos.list.getData();
// Optimistically update cache
utils.todos.list.setData(undefined, (old) => [
...(old ?? []),
{ id: 'temp-id', ...newTodo, completed: false },
]);
return { previousTodos };
},
// Rollback on error
onError: (err, newTodo, context) => {
utils.todos.list.setData(undefined, context?.previousTodos);
},
// Refetch on success
onSuccess: () => {
utils.todos.list.invalidate();
},
});
return (
<div>
{todos?.map(todo => (
<div key={todo.id}>{todo.title}</div>
))}
<button onClick={() => createMutation.mutate({ title: 'New Todo' })}>
Add Todo
</button>
</div>
);
}
Pattern 4: Subscriptions (WebSocket)
// lib/trpc/server.ts
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';
const ee = new EventEmitter();
export const appRouter = router({
// Subscribe to real-time updates
onPostAdded: publicProcedure.subscription(() => {
return observable<Post>((emit) => {
const onAdd = (post: Post) => emit.next(post);
ee.on('post:add', onAdd);
return () => {
ee.off('post:add', onAdd);
};
});
}),
// Mutation that triggers subscription
createPost: protectedProcedure
.input(z.object({ title: z.string(), content: z.string() }))
.mutation(async ({ input, ctx }) => {
const post = await ctx.db.post.create({ data: input });
// Emit event for subscribers
ee.emit('post:add', post);
return post;
}),
});
// Frontend usage
const subscription = trpc.onPostAdded.useSubscription(undefined, {
onData(post) {
console.log('New post:', post);
},
});
Server-Side Usage
In Server Components
// app/users/page.tsx
import { createTRPCContext, appRouter } from '@/lib/trpc/routers/_app';
export default async function UsersPage() {
// Create context for server-side calls
const ctx = await createTRPCContext();
const caller = appRouter.createCaller(ctx);
// Call tRPC procedure on server
const users = await caller.users.list({ limit: 10 });
return (
<div>
{users.map(user => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
In API Routes
// app/api/export-users/route.ts
import { NextResponse } from 'next/server';
import { createTRPCContext, appRouter } from '@/lib/trpc/routers/_app';
export async function GET() {
const ctx = await createTRPCContext();
const caller = appRouter.createCaller(ctx);
const users = await caller.users.list({ limit: 1000 });
return NextResponse.json(users);
}
Error Handling
Custom Errors
import { TRPCError } from '@trpc/server';
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `User with ID ${input.id} not found`,
});
}
return user;
}),
});
// Frontend error handling
const { data, error } = trpc.getUser.useQuery({ id: '123' });
if (error) {
if (error.data?.code === 'NOT_FOUND') {
return <div>User not found</div>;
}
if (error.data?.code === 'UNAUTHORIZED') {
return <div>Please log in</div>;
}
return <div>Error: {error.message}</div>;
}
Global Error Handler
// app/providers.tsx
export function Providers({ children }: Props) {
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
],
// Global error handling
onError: ({ error }) => {
if (error.data?.code === 'UNAUTHORIZED') {
toast.error('Please log in to continue');
router.push('/login');
} else if (error.data?.code === 'TOO_MANY_REQUESTS') {
toast.error('Too many requests. Please slow down.');
} else {
toast.error(error.message);
}
},
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
Performance Optimization
1. Request Batching
// lib/trpc/client.ts
import { httpBatchLink } from '@trpc/client';
export const trpc = createTRPCReact<AppRouter>();
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
maxURLLength: 2083, // Batch requests into single HTTP call
}),
],
});
// Multiple queries execute in single request
trpc.users.getById.useQuery({ id: '1' });
trpc.posts.list.useQuery({ limit: 10 });
trpc.comments.getRecent.useQuery();
// All 3 batched into 1 HTTP request!
2. Prefetching
// Server Component
export default async function ProductPage({ params }: Props) {
const ctx = await createTRPCContext();
const caller = appRouter.createCaller(ctx);
// Prefetch product on server
const product = await caller.products.getById({ id: params.id });
return (
<div>
<h1>{product.name}</h1>
<ClientComponent productId={params.id} />
</div>
);
}
3. Caching with TanStack Query
trpc.users.list.useQuery(
{ limit: 10 },
{
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
cacheTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
refetchOnWindowFocus: false,
}
);
Production Checklist
- ✅ Enable request batching (
httpBatchLink) - ✅ Implement authentication with protected procedures
- ✅ Add rate limiting middleware
- ✅ Set up error monitoring (Sentry)
- ✅ Use Zod for input validation
- ✅ Implement proper error codes
- ✅ Add logging middleware
- ✅ Test all procedures (unit + integration)
- ✅ Document complex procedures
- ✅ Set up CORS for cross-domain requests
- ✅ Enable compression for API responses
- ✅ Monitor API performance metrics
Real-World Performance
Before tRPC (REST API):
- 47 API endpoints
- 8 OpenAPI spec files
- 3 code generation scripts
- 12 type definition files
- Type drift: 23 issues/month
- API contract bugs: 8/month
After tRPC:
- 47 procedures (same functionality)
- 0 spec files
- 0 code generation
- 0 separate type files
- Type drift: 0 (impossible!)
- API contract bugs: 0
Common Pitfalls
❌ Not Using Zod Validation
// ❌ No validation
export const createUser = publicProcedure
.mutation(async ({ input }) => {
// input is `any` - no validation!
return await db.user.create({ data: input });
});
// ✅ With Zod validation
export const createUser = publicProcedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ input }) => {
// input is typed and validated!
return await db.user.create({ data: input });
});
❌ Exposing Sensitive Data
// ❌ Returning entire user object (includes password hash!)
export const getUser = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.user.findUnique({ where: { id: input.id } });
});
// ✅ Select only public fields
export const getUser = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.user.findUnique({
where: { id: input.id },
select: {
id: true,
name: true,
email: true,
// NOT password!
},
});
});
Summary
tRPC revolutionizes full-stack TypeScript development:
Key Benefits:
- 🔒 End-to-End Type Safety - Backend changes = instant frontend errors
- 🚫 No Code Generation - TypeScript's type system handles everything
- ⚡ Request Batching - Multiple queries in single HTTP request
- 🛡️ Zod Validation - Runtime input validation
- 🔑 Authentication - Built-in context and middleware
- 📦 TanStack Query - Caching, optimistic updates, prefetching
- 🎯 TypeScript Native - Designed for TypeScript from day one
When to Use:
- ✅ Full-stack TypeScript monorepos
- ✅ Next.js applications
- ✅ Internal APIs (same codebase)
- ✅ Rapid development without contracts
When NOT to Use:
- ❌ Public APIs consumed by non-TypeScript clients
- ❌ Microservices with separate teams/languages
- ❌ Need for REST/GraphQL standards
Frequently Asked Questions
Can I use tRPC with non-Next.js frameworks?
Yes! tRPC works with any framework: Express, Fastify, Remix, SvelteKit, etc. The core is framework-agnostic, with adapters for each platform.
Does tRPC work with React Native?
Yes! Use the @trpc/client with the httpBatchLink adapter. All features work except Server Components (not applicable to React Native).
How does tRPC compare to GraphQL?
tRPC advantages:
- No code generation or build step
- Simpler setup (no schema files)
- Better TypeScript inference
- Lower learning curve
GraphQL advantages:
- Established industry standard
- Works with any client language
- Public API ecosystem
Use GraphQL for public APIs, tRPC for internal TypeScript monorepos.
Can I call tRPC procedures from other backend services?
Yes! Create a server-side caller:
const caller = appRouter.createCaller(ctx);
const user = await caller.users.getById({ id: '123' });
Does tRPC support file uploads?
Yes, but it requires special handling. Use FormData and the transformer option, or consider regular REST endpoints for file uploads (simpler).
How do I version my tRPC API?
Create versioned routers:
const v1Router = router({ /* v1 procedures */ });
const v2Router = router({ /* v2 procedures */ });
const appRouter = router({
v1: v1Router,
v2: v2Router,
});
Can I use tRPC with WebSockets?
Yes! tRPC supports subscriptions via WebSockets using the wsLink adapter. Perfect for real-time features.
Does tRPC support middleware?
Yes! Middleware is a core feature used for authentication, logging, rate limiting, error handling, and more.
tRPC isn't just about type safety—it's about confidence. When your code compiles, you know your API contract is correct. No more API documentation drift, no more runtime type mismatches, no more "it works on my machine" bugs. Just pure, type-safe bliss.