Introduction
When Alex Johansson first introduced tRPC in 2020, the TypeScript ecosystem was grappling with a persistent problem: how to maintain type safety across the client-server boundary without heavy tooling or runtime overhead. REST APIs required manual type synchronization, GraphQL demanded schema definitions and code generation, and OpenAPI specs felt like writing the same thing twice. tRPC offered a radical alternativeβwhat if TypeScript itself was the API contract?
This early exploration of tRPC examines its foundational principles, the developer experience breakthrough it represents, and why it quickly became the darling of the TypeScript monorepo community. We'll trace the journey from the problem space through implementation, showing how tRPC's zero-overhead type safety fundamentally changes how you think about API development.
Understanding tRPC: The Type Safety Revolution
The Traditional API Problem
Every full-stack TypeScript developer has experienced the frustration of type desynchronization. You define a user model on the server, create an API endpoint to return it, then manually define a TypeScript interface on the client that hopefully matches. When the backend changes a field name or adds a new property, the client types silently become stale.
// The traditional approach - types duplicated, easily desynchronized
// server/models/user.ts
interface User {
id: string;
name: string;
email: string;
createdAt: Date; // Changed from 'created' to 'createdAt'
}
// client/types/user.ts
interface User {
id: string;
name: string;
email: string;
created: Date; // STALE - still uses old field name
}
// This compiles fine but breaks at runtime!How tRPC Flips the Model
tRPC's insight is elegant: if your entire stack is TypeScript, why define types twice? Your server procedures already have TypeScript types for their inputs and outputs. tRPC simply propagates those types to the client at compile time with zero runtime cost.
// tRPC approach - types defined once, propagated automatically
// server/router.ts
const userRouter = router({
get: procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
// Return type is inferred from what you actually return
return db.user.findUnique({ where: { id: input.id } });
// TypeScript infers: { id: string; name: string; email: string; createdAt: Date }
}),
});
// client - types flow automatically
const user = await trpc.user.get.query({ id: '123' });
// user is automatically typed - no manual definition needed
// If server changes a field, client types update immediatelyZero Runtime Overhead
Unlike GraphQL or REST with OpenAPI validation, tRPC adds no runtime type checking to your API calls. The type information exists purely at compile time. At runtime, tRPC is essentially a thin JSON-RPC layer over HTTP. The types are erased during compilation, leaving behind nothing but standard function calls.
This means:
- No schema parsing at runtime
- No code generation step in your build
- No additional bundle size for type infrastructure
- No performance penalty for type safety
Step-by-Step Implementation
Project Structure for tRPC
A well-organized tRPC project typically uses a monorepo structure:
my-app/
βββ packages/
β βββ api/
β βββ src/
β β βββ routers/
β β β βββ user.ts
β β β βββ post.ts
β β β βββ index.ts
β β βββ trpc.ts
β β βββ context.ts
β βββ package.json
βββ apps/
β βββ web/ # React/Next.js frontend
β βββ server/ # Express/Fastify backend
βββ package.json
Server Setup
// packages/api/src/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { CreateExpressContextOptions } from '@trpc/server/adapters/express';
import { ZodError } from 'zod';
// Define your context - available in all procedures
export const createContext = ({ req, res }: CreateExpressContextOptions) => {
const token = req.headers.authorization?.replace('Bearer ', '');
const user = token ? verifyToken(token) : null;
return {
req,
res,
user,
db: prisma,
};
};
export type Context = Awaited<ReturnType<typeof createContext>>;
// Initialize tRPC with context
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const router = t.router;
export const procedure = t.procedure;
// Authentication middleware
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: { ...ctx, user: ctx.user },
});
});
export const protectedProcedure = t.procedure.use(isAuthed);Building Procedures
// packages/api/src/routers/user.ts
import { z } from 'zod';
import { router, procedure, protectedProcedure } from '../trpc';
export const userRouter = router({
// Public procedure - anyone can access
byId: procedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id },
select: { id: true, name: true, avatar: true },
});
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
});
}
return user;
}),
// Protected procedure - requires authentication
me: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.user.findUnique({
where: { id: ctx.user.id },
include: { settings: true },
});
}),
// Mutation with complex validation
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(2).max(50),
bio: z.string().max(500).optional(),
avatar: z.string().url().optional(),
})
)
.mutation(async ({ input, ctx }) => {
return ctx.db.user.update({
where: { id: ctx.user.id },
data: input,
});
}),
});Client Setup with React Query
// apps/web/src/utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@my-app/api';
export const trpc = createTRPCReact<AppRouter>();
// apps/web/src/App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from './utils/trpc';
import { useState } from 'react';
function App() {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:4000/trpc',
headers() {
const token = localStorage.getItem('token');
return token ? { authorization: `Bearer ${token}` } : {};
},
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<AppContent />
</QueryClientProvider>
</trpc.Provider>
);
}Component Usage with Full Type Safety
// apps/web/src/components/UserProfile.tsx
import { trpc } from '../utils/trpc';
export function UserProfile({ userId }: { userId: string }) {
// TypeScript knows exactly what this returns
const { data: user, isLoading, error } = trpc.user.byId.useQuery({ id: userId });
const updateProfile = trpc.user.updateProfile.useMutation({
onSuccess: () => {
// Invalidate and refetch
trpc.user.me.invalidate();
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
// TypeScript knows user has { id, name, avatar }
return (
<div>
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
{/* Full autocomplete on user properties */}
</div>
);
}Real-World Use Cases
Use Case 1: Rapid Prototyping
tRPC excels at rapid prototyping because you can iterate on your API without updating type definitions. Add a new field to your database model, return it from a procedure, and immediately use it in your frontendβall with full type safety and zero configuration changes.
Use Case 2: Monorepo Teams
In monorepo setups with separate frontend and backend teams, tRPC eliminates the "API contract negotiation" problem. The backend defines procedures, and the frontend automatically gets correct types. No API documentation to keep in sync, no code generation step to remember.
Use Case 3: Full-Stack Feature Development
When building a feature that spans client and server, tRPC's type flow keeps everything coherent. You define your data requirements in one place and the types cascade through the entire stack.
// Adding a new feature - types propagate automatically
const notificationRouter = router({
list: protectedProcedure
.input(z.object({
unreadOnly: z.boolean().default(true),
limit: z.number().min(1).max(100).default(20),
}))
.query(async ({ input, ctx }) => {
return ctx.db.notification.findMany({
where: {
userId: ctx.user.id,
...(input.unreadOnly && { read: false }),
},
take: input.limit,
orderBy: { createdAt: 'desc' },
});
}),
markRead: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
return ctx.db.notification.update({
where: { id: input.id },
data: { read: true },
});
}),
});
// Frontend - immediately available with full types
const { data: notifications } = trpc.notification.list.useQuery({
unreadOnly: true,
});
// notifications is typed as Notification[] automaticallyBest Practices for Production
- Keep routers focused: Each router should handle a single domain (users, posts, comments)
- Use Zod schemas everywhere: Input validation prevents runtime errors and provides clear error messages
- Implement proper error codes: Use tRPC's built-in error codes (NOT_FOUND, FORBIDDEN, etc.) consistently
- Add request logging: Create middleware that logs procedure calls, duration, and errors
- Use
selectfor database queries: Only return the fields your frontend actually needs - Implement rate limiting: Protect your API from abuse with middleware-based rate limiting
- Cache aggressively: Leverage React Query's caching with appropriate stale times
- Write integration tests: Test procedures with
createCallerfor fast, reliable tests
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Exposing sensitive fields | Security vulnerability | Use Prisma select or output transformers |
| N+1 queries in procedures | Poor performance | Use DataLoader or batch queries |
| Missing error boundaries | White screen of death | Add error boundaries around tRPC calls |
| Over-fetching data | Slow responses | Select only needed fields |
| No input validation | Runtime errors | Always use Zod for procedure inputs |
| Stale cache issues | Incorrect data displayed | Configure proper invalidation strategies |
Performance Optimization
Request Batching
tRPC's httpBatchLink automatically batches multiple procedure calls into a single HTTP request. This is particularly powerful for component-based architectures where multiple components need different data:
// These three calls are batched into one HTTP request
function Dashboard() {
const user = trpc.user.me.useQuery();
const tasks = trpc.task.list.useQuery({ limit: 5 });
const notifications = trpc.notification.list.useQuery({ unreadOnly: true });
// 3 queries = 1 HTTP request (batched automatically)
}Prefetching
function TaskList() {
const utils = trpc.useUtils();
// Prefetch task details on hover
const handleHover = (taskId: string) => {
utils.task.byId.prefetch({ id: taskId });
};
return (
<ul>
{tasks.map(task => (
<li key={task.id} onMouseEnter={() => handleHover(task.id)}>
{task.title}
</li>
))}
</ul>
);
}Comparison with Alternatives
| Feature | tRPC | REST + Types | GraphQL |
|---|---|---|---|
| Type safety | Automatic | Manual | Code gen required |
| Runtime overhead | None | Low | Schema parsing |
| Learning curve | Low | Low | High |
| Bundle size | ~15KB | 0 | 30KB+ |
| Code generation | None | None | Required |
| Schema definition | TypeScript | OpenAPI | GraphQL SDL |
| Real-time | Subscriptions | Custom | Subscriptions |
Advanced Patterns
Conditional Procedure Execution
// Procedures that behave differently based on auth status
const semiProtected = procedure.use(async ({ ctx, next }) => {
// Allow both authenticated and anonymous access
// But provide different data based on auth status
return next({
ctx: {
...ctx,
isAuthed: !!ctx.user,
},
});
});
const postRouter = router({
get: semiProtected
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const post = await ctx.db.post.findUnique({
where: { id: input.id },
include: {
// Only include sensitive data for authenticated users
...(ctx.isAuthed && {
analytics: true,
draftContent: true,
}),
},
});
return post;
}),
});Testing Strategies
// Test procedures without HTTP layer
import { appRouter } from './routers';
import { createInnerContext } from './context';
describe('User Router', () => {
const mockDb = createMockDb();
const authenticatedCtx = createInnerContext({
user: { id: 'user-1', role: 'ADMIN' },
db: mockDb,
});
const unauthenticatedCtx = createInnerContext({
user: null,
db: mockDb,
});
test('authenticated user can fetch own profile', async () => {
const caller = appRouter.createCaller(authenticatedCtx);
const user = await caller.user.me();
expect(user.id).toBe('user-1');
expect(user.settings).toBeDefined();
});
test('unauthenticated user cannot access protected route', async () => {
const caller = appRouter.createCaller(unauthenticatedCtx);
await expect(caller.user.me()).rejects.toThrow('UNAUTHORIZED');
});
});Future Outlook
tRPC's early adoption signals a broader shift toward "type-first" API development. As TypeScript continues its march toward ubiquity, solutions that leverage the type system rather than fighting it will increasingly dominate. Key trends to watch:
- Framework convergence: Expect deeper integrations with Next.js, SvelteKit, and other meta-frameworks
- Edge compatibility: Better support for edge runtimes (Cloudflare Workers, Deno Deploy)
- Schema evolution: Tools for versioning and migrating tRPC APIs
- External API generation: Auto-generating REST or GraphQL endpoints from tRPC routers for third-party consumers
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.
tRPC with Next.js App Router
For Next.js App Router projects, tRPC integrates via the @trpc/next package with server actions and React Server Components. The recommended pattern uses a dedicated API route handler:
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers';
export async function GET(req: Request) {
return fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createTRPCContext({ req }),
});
}
export async function POST(req: Request) {
return fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createTRPCContext({ req }),
});
}This setup provides full type safety between server and client components with zero configuration overhead.
Conclusion
tRPC represents a fundamental rethinking of how we build APIs in TypeScript. By making the type system the contract, it eliminates entire categories of bugs and boilerplate. The zero-runtime-overhead approach means you pay nothing for this safety at runtimeβit's purely a compile-time benefit.
Key takeaways:
- Types propagate automatically: Change your server, see errors on the client immediately
- No code generation: The TypeScript compiler is your code generator
- Minimal learning curve: If you know TypeScript, you know tRPC
- Perfect for monorepos: Shared types across packages with zero configuration
- Production-ready: Error handling, middleware, and caching patterns built-in
If you're building a full-stack TypeScript application and value developer experience, tRPC isn't just an optionβit's the future of type-safe API development.