Introduction
In the world of full-stack TypeScript development, tRPC has emerged as a game-changing library that eliminates the gap between your backend and frontend. Unlike traditional REST or GraphQL APIs that require schema definitions, code generation, or runtime validation layers, tRPC leverages TypeScript's type system to provide end-to-end type safety with zero runtime overhead. When you change a backend procedure, your frontend code immediately reflects the change—no code generation, no schema synchronization, no manual type definitions.
This comprehensive guide explores tRPC's architecture, implementation patterns, and best practices for building production-ready APIs. We'll cover everything from basic setup to advanced patterns like middleware chains, subscription handling, and integration with popular frameworks. By the end, you'll understand why tRPC has become the go-to choice for TypeScript monorepos and how to leverage its full potential in your projects.
Understanding tRPC: Core Concepts
The Problem tRPC Solves
Traditional API development in TypeScript projects suffers from a fundamental type disconnect. Your backend defines routes and data shapes, your frontend consumes them, but there's no compile-time guarantee they match. This leads to:
- Runtime errors when API responses don't match frontend expectations
- Manual type duplication between backend models and frontend interfaces
- Code generation overhead with tools like GraphQL Code Generator or OpenAPI generators
- Synchronization issues when backend changes aren't reflected in frontend types
tRPC solves this by treating your TypeScript types as the single source of truth. Your API procedures are just TypeScript functions, and their input/output types flow automatically to the client.
Architecture Overview
tRPC follows a layered architecture:
- Router Layer: Defines your API structure using nested routers
- Procedure Layer: Individual API endpoints (queries, mutations, subscriptions)
- Middleware Layer: Cross-cutting concerns like auth, logging, validation
- Adapter Layer: Connects to your HTTP framework (Express, Fastify, Next.js)
- Client Layer: Type-safe client that mirrors your router structure
// The core tRPC concept - types flow from server to client
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
return { id: input.id, name: 'John', email: 'john@example.com' };
}),
});
// On the client - full type safety, zero code generation
const user = await trpc.getUser.query({ id: '123' });
// user is automatically typed as { id: string; name: string; email: string }Step-by-Step Implementation
Setting Up the Server
Let's build a complete tRPC server with a real-world example: a task management API.
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import superjson from 'superjson';
// Context type for dependency injection
interface Context {
userId: string | null;
db: Database;
}
const t = initTRPC.context<Context>().create({
transformer: superjson, // Handles Date, Map, Set, etc.
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof z.ZodError ? error.cause.flatten() : null,
},
};
},
});
// Base router and procedure helpers
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;Defining Routers and Procedures
// server/routers/task.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
const taskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().optional(),
priority: z.enum(['low', 'medium', 'high', 'urgent']),
dueDate: z.string().datetime().optional(),
assigneeId: z.string().uuid().optional(),
});
export const taskRouter = router({
// Query - read operation
list: protectedProcedure
.input(
z.object({
status: z.enum(['todo', 'in-progress', 'done']).optional(),
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(),
})
)
.query(async ({ input, ctx }) => {
const { status, priority, limit, cursor } = input;
const tasks = await ctx.db.task.findMany({
where: {
userId: ctx.userId,
...(status && { status }),
...(priority && { priority }),
},
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
let nextCursor: string | undefined;
if (tasks.length > limit) {
const next = tasks.pop();
nextCursor = next?.id;
}
return { tasks, nextCursor };
}),
// Query - single item
byId: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const task = await ctx.db.task.findUnique({
where: { id: input.id, userId: ctx.userId },
include: { assignee: true, comments: true },
});
if (!task) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Task ${input.id} not found`,
});
}
return task;
}),
// Mutation - create
create: protectedProcedure
.input(taskSchema)
.mutation(async ({ input, ctx }) => {
return ctx.db.task.create({
data: {
...input,
userId: ctx.userId,
status: 'todo',
},
});
}),
// Mutation - update with optimistic updates support
update: protectedProcedure
.input(
z.object({
id: z.string().uuid(),
data: taskSchema.partial(),
})
)
.mutation(async ({ input, ctx }) => {
const { id, data } = input;
return ctx.db.task.update({
where: { id, userId: ctx.userId },
data,
});
}),
// Mutation - delete
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
await ctx.db.task.delete({
where: { id, userId: ctx.userId },
});
return { success: true };
}),
});Composing the Root Router
// server/root.ts
import { router } from './trpc';
import { taskRouter } from './routers/task';
import { userRouter } from './routers/user';
import { projectRouter } from './routers/project';
export const appRouter = router({
task: taskRouter,
user: userRouter,
project: projectRouter,
});
// Export type definition for client usage
export type AppRouter = typeof appRouter;Client Integration with React
// client/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/root';
export const trpc = createTRPCReact<AppRouter>();
// client/providers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from './trpc';
import superjson from 'superjson';
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 2,
},
},
}));
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson,
headers() {
const token = localStorage.getItem('auth-token');
return token ? { authorization: `Bearer ${token}` } : {};
},
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}Using tRPC in Components
// client/components/TaskList.tsx
import { trpc } from '../trpc';
import { useState } from 'react';
export function TaskList() {
const [status, setStatus] = useState<'todo' | 'in-progress' | 'done'>('todo');
// Fully typed query - TypeScript knows the exact return shape
const { data, isLoading, fetchNextPage, hasNextPage } = trpc.task.list.useInfiniteQuery(
{ status, limit: 20 },
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
);
// Fully typed mutation with optimistic updates
const utils = trpc.useUtils();
const updateTask = trpc.task.update.useMutation({
onMutate: async ({ id, data }) => {
await utils.task.list.cancel();
const previous = utils.task.list.getInfiniteData({ status });
utils.task.list.setInfiniteData({ status }, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
tasks: page.tasks.map((task) =>
task.id === id ? { ...task, ...data } : task
),
})),
};
});
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
utils.task.list.setInfiniteData({ status }, context.previous);
}
},
onSettled: () => {
utils.task.list.invalidate();
},
});
if (isLoading) return <TaskListSkeleton />;
return (
<div>
<StatusFilter value={status} onChange={setStatus} />
{data?.pages.flatMap((page) =>
page.tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onStatusChange={(newStatus) =>
updateTask.mutate({ id: task.id, data: { status: newStatus } })
}
/>
))
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>Load more</button>
)}
</div>
);
}Real-World Use Cases
Use Case 1: SaaS Dashboard with Complex Data Requirements
A project management SaaS needs to display tasks, projects, team members, and analytics—all with real-time updates. tRPC's composability shines here:
// Complex query with related data
const dashboardRouter = router({
overview: protectedProcedure
.input(z.object({ projectId: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const [tasks, members, activity] = await Promise.all([
ctx.db.task.groupBy({
by: ['status'],
where: { projectId: input.projectId },
_count: true,
}),
ctx.db.membership.findMany({
where: { projectId: input.projectId },
include: { user: true },
}),
ctx.db.activity.findMany({
where: { projectId: input.projectId },
take: 10,
orderBy: { createdAt: 'desc' },
include: { user: true },
}),
]);
return { tasks, members, activity };
}),
});Use Case 2: Multi-Tenant Application
tRPC middleware makes multi-tenancy straightforward:
const enforceTenantAccess = middleware(async ({ ctx, next, input }) => {
const tenantId = (input as any).tenantId;
if (!tenantId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'tenantId required' });
}
const membership = await ctx.db.membership.findUnique({
where: {
userId_tenantId: { userId: ctx.userId!, tenantId },
},
});
if (!membership) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'No access to this tenant' });
}
return next({
ctx: { ...ctx, tenantId, role: membership.role },
});
});
const tenantProcedure = protectedProcedure.use(enforceTenantAccess);Use Case 3: Real-Time Collaboration
tRPC subscriptions enable real-time features:
import { observable } from '@trpc/server/observable';
const collaborationRouter = router({
onTaskChange: protectedProcedure
.input(z.object({ projectId: z.string() }))
.subscription(({ input, ctx }) => {
return observable<{ type: string; task: Task }>((emit) => {
const listener = (event: TaskChangeEvent) => {
if (event.projectId === input.projectId) {
emit.next(event);
}
};
eventEmitter.on('taskChange', listener);
return () => {
eventEmitter.off('taskChange', listener);
};
});
}),
});Best Practices for Production
- Use Zod for all inputs: Validates data at the boundary and provides excellent error messages
- Implement proper error handling: Use tRPC's error codes (NOT_FOUND, FORBIDDEN, etc.) consistently
- Add authentication middleware early: Create a
protectedProcedurebase that all authenticated routes use - Use infinite queries for lists: Provides better UX with pagination and cursor-based navigation
- Implement optimistic updates: Improves perceived performance for mutations
- Add request logging middleware: Track procedure calls, duration, and errors
- Use SuperJSON transformer: Handles Date, Map, Set, and other non-JSON-serializable types
- Structure routers by domain: Group related procedures into domain-specific routers
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| No input validation | Security vulnerabilities, runtime errors | Always use Zod schemas for procedure inputs |
| Exposing sensitive data | Data leaks | Use output transformers to strip sensitive fields |
| N+1 queries | Performance degradation | Use DataLoader or batch queries in resolvers |
| Missing error handling | Unhelpful error messages | Use tRPC error codes and custom error formatter |
| Over-fetching data | Slow responses | Use .select() to only return needed fields |
| No rate limiting | API abuse | Add rate limiting middleware |
Performance Optimization
Request Batching
tRPC automatically batches multiple procedure calls into a single HTTP request using httpBatchLink. This reduces network overhead significantly:
// These 3 calls become 1 HTTP request
const [user, tasks, projects] = await Promise.all([
trpc.user.me.query(),
trpc.task.list.query({ limit: 10 }),
trpc.project.list.query(),
]);Caching Strategies
// Configure React Query for optimal caching
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000, // 30 minutes
refetchOnWindowFocus: false,
},
},
});
// Prefetch data for instant navigation
function TaskPage() {
const utils = trpc.useUtils();
const prefetchTask = (id: string) => {
utils.task.byId.prefetch({ id });
};
return (
<TaskList onHover={prefetchTask} />
);
}Comparison with Alternatives
| Feature | tRPC | REST | GraphQL | OpenAPI |
|---|---|---|---|---|
| Type safety | Automatic | Manual | Code gen | Code gen |
| Runtime overhead | Zero | Low | Medium | Low |
| Learning curve | Low | Low | High | Medium |
| Schema definition | TypeScript only | OpenAPI spec | GraphQL SDL | OpenAPI spec |
| Tooling required | Minimal | Minimal | Heavy | Medium |
| Real-time support | Subscriptions | WebSockets | Subscriptions | N/A |
| Bundle size | ~15KB | 0 | ~30KB+ | Varies |
Advanced Patterns
Conditional Middleware
const rateLimitMiddleware = middleware(async ({ ctx, next, type, path }) => {
const rateLimitKey = `${ctx.userId}:${path}`;
const current = await redis.incr(rateLimitKey);
if (current === 1) {
await redis.expire(rateLimitKey, 60);
}
if (current > 100) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded',
});
}
return next();
});Output Transformers
const stripSensitive = middleware(async ({ ctx, next }) => {
const result = await next();
if (result.data && 'email' in result.data) {
const { email, ...rest } = result.data;
return { ...result, data: { ...rest, email: maskEmail(email) } };
}
return result;
});Testing Strategies
import { appRouter } from './server/root';
import { createInnerTRPCContext } from './server/trpc';
describe('Task Router', () => {
const ctx = createInnerTRPCContext({
userId: 'test-user-id',
db: mockDb,
});
const caller = appRouter.createCaller(ctx);
test('creates task with valid input', async () => {
const task = await caller.task.create({
title: 'Test Task',
priority: 'high',
});
expect(task.id).toBeDefined();
expect(task.title).toBe('Test Task');
expect(task.status).toBe('todo');
});
test('rejects unauthorized access', async () => {
const unauthedCtx = createInnerTRPCContext({ userId: null, db: mockDb });
const unauthedCaller = appRouter.createCaller(unauthedCtx);
await expect(
unauthedCaller.task.list({})
).rejects.toThrow('UNAUTHORIZED');
});
});Future Outlook
tRPC continues to evolve rapidly. Key developments to watch:
- tRPC v11: Improved middleware patterns, better error handling, and enhanced subscription support
- Framework integrations: Deeper integration with Next.js App Router, SvelteKit, and SolidStart
- Edge runtime support: Better compatibility with Cloudflare Workers and Vercel Edge Functions
- OpenAPI generation: Automatic REST API generation from tRPC routers for external consumers
tRPC Error Handling Patterns
tRPC provides structured error handling through its error formatting system. Use TRPCError to throw typed errors from procedures that the client can handle specifically. Implement error formatting in your tRPC configuration to control which error details are exposed to clients. Use the onError handler to log errors and send them to monitoring services. Create middleware that catches and transforms common error types (database errors, validation errors, authentication errors) into appropriate tRPC error codes.
tRPC Deployment Patterns
Deploy tRPC servers in various environments including serverless functions, Docker containers, and edge runtimes. For AWS Lambda, use the @trpc/server/adapters/aws-lambda adapter and configure appropriate memory and timeout settings. For edge deployments on Cloudflare Workers or Vercel Edge Functions, use the Fetch adapter which works with the Web Fetch API. Implement health check endpoints alongside tRPC routers for monitoring. Use connection pooling for database-backed tRPC servers to handle concurrent requests efficiently.
Production Deployment and Operations
Running backend services in production requires attention to reliability, observability, and operational concerns that don't exist in development environments. Proper deployment practices ensure your service remains available and performant under real-world conditions.
Graceful Shutdown Handling
Implement graceful shutdown to prevent request failures during deployments and restarts:
const server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
async function gracefulShutdown(signal) {
console.log(`Received ${signal}, starting graceful shutdown...`);
// Stop accepting new connections
server.close(async () => {
console.log('HTTP server closed');
try {
// Wait for existing requests to complete (with timeout)
await Promise.race([
waitForActiveRequests(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Shutdown timeout')), 30000)
),
]);
// Close database connections
await db.destroy();
await redis.quit();
console.log('Graceful shutdown completed');
process.exit(0);
} catch (error) {
console.error('Error during shutdown:', error);
process.exit(1);
}
});
// Force shutdown after timeout
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 35000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));Structured Logging
Replace console.log with structured logging that supports log aggregation and querying:
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level(label) {
return { level: label };
},
},
serializers: {
err: pino.stdSerializers.err,
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
},
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie'],
remove: true,
},
});
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
logger.info({
req,
res,
responseTime: Date.now() - start,
}, `${req.method} ${req.url} ${res.statusCode}`);
});
next();
});Rate Limiting and Abuse Prevention
Protect your API endpoints with rate limiting that adapts to different client types:
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const apiLimiter = rateLimit({
store: new RedisStore({ client: redisClient }),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.user?.id || req.ip,
handler: (req, res) => {
logger.warn({ ip: req.ip, user: req.user?.id }, 'Rate limit exceeded');
res.status(429).json({
error: 'Too many requests',
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
});
},
});
app.use('/api/', apiLimiter);These operational practices form the foundation of a reliable production service that can handle real-world traffic patterns and failure scenarios.
Community Resources and Further Learning
The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.
Curated Learning Pathways
Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.
Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.
Contributing to Open Source
Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.
# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
# Run the project's contribution setup
npm run setup:dev
npm run test # Ensure tests pass before making changes
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
Closes #1234"
git push origin fix/issue-descriptionBuilding a Technical Knowledge Base
Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.
Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.
Staying Current with Industry Trends
Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.
Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.
Mentorship and Knowledge Sharing
Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.
Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.
Conclusion
tRPC represents a paradigm shift in how we build APIs in TypeScript projects. By eliminating the type gap between client and server, it reduces bugs, improves developer experience, and removes entire categories of boilerplate code. The zero-runtime-overhead approach means you get all these benefits without sacrificing performance.
Key takeaways:
- Type safety without code generation: tRPC leverages TypeScript's type system directly
- Minimal boilerplate: Define procedures, get automatic client types
- Excellent DX: Hot reloading, autocomplete, and compile-time error checking
- Production-ready: Middleware, error handling, and caching patterns built-in
- Perfect for monorepos: Shared types between packages with zero configuration
If you're building a full-stack TypeScript application, tRPC should be at the top of your evaluation list. The developer experience improvements alone justify the adoption, and the type safety guarantees will save you countless hours of debugging.