tRPC: End-to-End Type-Safe APIs
Traditional API development creates a dangerous gap between server and client code. You build a REST endpoint that returns a user object, but your frontend has no idea what shape that object will have at compile time. Rename user.name to user.fullName on the server and TypeScript won't warn you — you'll only discover the bug when users hit that broken component in production. tRPC eliminates this problem entirely by letting TypeScript types flow seamlessly from your server to your client, with no code generation, no schemas, and no extra tooling. What you define on the server is exactly what the client sees — instantly, automatically, and with full editor autocomplete.
tRPC has over 35,000 GitHub stars and is the backbone of create-t3-app — the community's most popular full-stack TypeScript starter. Companies like Cal.com, Ping.gg, and many others have adopted it because it eliminates an entire category of frontend/backend contract bugs with zero runtime overhead.
What is tRPC?
tRPC stands for TypeScript Remote Procedure Call. It's a library that lets you invoke server-side functions from the client as if they were local functions, with full end-to-end TypeScript type inference. Unlike REST — which requires you to manually maintain type definitions for every endpoint — or GraphQL — which needs a schema language and a code generation step — tRPC uses TypeScript's own type system to propagate types automatically from server to client.
The core idea is simple: you define procedures (functions) on the server inside a router. The router's TypeScript type is exported and shared with the client. The client uses that type to get full autocompletion and type-checking with zero extra work. There is no JSON Schema, no OpenAPI spec, no GraphQL SDL — just TypeScript.
- Zero schema overhead: No separate definition language to maintain
- Instant type propagation: Change a return type on the server, the client breaks at compile time
- No code generation: Types are inferred at build time from your actual code
- React Query integration: First-class support for caching, loading states, and optimistic updates
- Zod validation: Runtime input validation with automatic TypeScript type inference
tRPC's type safety flows from server definition to client without code generation
Setting Up Your tRPC Project
tRPC works best in a monorepo where both server and client share the same TypeScript project. The most common setup is Next.js, where API routes and frontend components live in the same codebase.
Create a Next.js TypeScript project
Start with a fresh Next.js app, or use create-t3-app for an opinionated full setup with tRPC, Prisma, and NextAuth pre-configured.
# Option 1: Plain Next.js
npx create-next-app@latest my-trpc-app --typescript --app
# Option 2: create-t3-app (recommended — includes tRPC + Prisma + Auth)
npm create t3-app@latest my-t3-app
Install tRPC and dependencies
Install the core tRPC packages, React Query adapter, and Zod for input validation.
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next
npm install @tanstack/react-query zod
npm install @tanstack/react-query-devtools --save-dev
Initialize tRPC on the server
Create a central trpc.ts file that exports your procedure builders and router factory.
Create a client-side tRPC instance
Export a typed React client that mirrors your server's router type, giving you full autocomplete in components.
Building Your First Router
The server setup involves two files: a trpc.ts initializer that creates your procedure builders, and one or more router files that define your procedures. This separation keeps your code clean as the API grows.
import { initTRPC, TRPCError } from '@trpc/server';
import { type Context } from './context';
// Initialize tRPC with your context type
const t = initTRPC.context<Context>().create();
// Export reusable router and procedure builders
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
// Reusable auth middleware
const isAuthed = middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be signed in to perform this action',
});
}
return next({
ctx: {
...ctx,
// Narrows user type to non-null after this middleware
user: ctx.session.user,
},
});
});
// Protected procedure — requires authentication
export const protectedProcedure = t.procedure.use(isAuthed);
Now define your routers. tRPC encourages splitting your API into focused sub-routers (users, posts, etc.) that are merged into one root router:
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { db } from '../db';
export const userRouter = router({
// Query: fetch all users (public)
getAll: publicProcedure.query(async () => {
return db.user.findMany({
select: { id: true, name: true, email: true, createdAt: true },
});
}),
// Query: fetch one user by ID (public)
getById: publicProcedure
.input(z.object({ id: z.string().cuid() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
return user;
}),
// Mutation: update profile (protected)
updateProfile: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
}))
.mutation(async ({ input, ctx }) => {
return db.user.update({
where: { id: ctx.user.id },
data: input,
});
}),
});
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
// Root router — merge all sub-routers
export const appRouter = router({
users: userRouter,
posts: postRouter,
});
// Export the type — this is what the client uses for inference
export type AppRouter = typeof appRouter;
export type AppRouter = typeof appRouter — this single line is why tRPC works. The client imports this type (not the implementation), and TypeScript infers every procedure's input and output types automatically. No runtime cost, no sync required.
Queries, Mutations, and Subscriptions
tRPC has three procedure types that map cleanly to common API patterns. Understanding when to use each one makes your API intuitive to consume.
Queries fetch data and are called with .useQuery() on the client. They are cached by React Query and re-fetched automatically when the cache is stale.
// Server — define the query
getAll: publicProcedure.query(async () => {
return db.post.findMany({ orderBy: { createdAt: 'desc' } });
}),
// Client — consume the query
function PostList() {
const { data: posts, isLoading, error } = trpc.posts.getAll.useQuery();
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Mutations modify data and are called with .useMutation(). They do not run automatically — you call mutate() or mutateAsync() imperatively.
// Server — define the mutation
create: protectedProcedure
.input(z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
published: z.boolean().default(false),
}))
.mutation(async ({ input, ctx }) => {
return db.post.create({
data: { ...input, authorId: ctx.user.id },
});
}),
// Client — consume the mutation
function CreatePost() {
const utils = trpc.useUtils();
const createPost = trpc.posts.create.useMutation({
onSuccess: () => {
// Invalidate the list cache so it re-fetches
utils.posts.getAll.invalidate();
},
});
return (
<button
onClick={() => createPost.mutate({ title: 'Hello', content: '...' })}
disabled={createPost.isPending}
>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
);
}
Subscriptions stream data over WebSockets using .useSubscription(). They require a WebSocket-capable server (like a standalone Node.js server, not Next.js API routes).
// Server — define the subscription (requires WS adapter)
import { observable } from '@trpc/server/observable';
onNewMessage: publicProcedure
.input(z.object({ roomId: z.string() }))
.subscription(({ input }) => {
return observable<Message>(emit => {
const handler = (message: Message) => {
if (message.roomId === input.roomId) {
emit.next(message);
}
};
messageEmitter.on('message', handler);
// Clean up on unsubscribe
return () => messageEmitter.off('message', handler);
});
}),
// Client — consume the subscription
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
trpc.chat.onNewMessage.useSubscription(
{ roomId },
{
onData: (message) => {
setMessages(prev => [...prev, message]);
},
}
);
return <MessageList messages={messages} />;
}
Input Validation with Zod
tRPC uses Zod for input validation by design. Zod schemas serve two purposes simultaneously: they validate incoming data at runtime (guarding against bad or malicious input) and they provide TypeScript types automatically without any separate type declaration.
Even though tRPC provides type safety between your own code, clients can always send arbitrary HTTP requests. Zod validation runs on the server at runtime, ensuring that data actually matches the expected shape before your procedure logic executes. Never skip .input() on procedures that accept user data.
import { z } from 'zod';
// Basic object schema
const createUserInput = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().email('Invalid email address'),
age: z.number().int().min(13).max(120).optional(),
role: z.enum(['user', 'admin', 'moderator']).default('user'),
});
// Nested schemas
const createPostInput = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string().max(30)).max(10).default([]),
metadata: z.object({
slug: z.string().regex(/^[a-z0-9-]+$/),
published: z.boolean().default(false),
publishedAt: z.date().optional(),
}),
});
// Use in a procedure
createPost: protectedProcedure
.input(createPostInput)
.mutation(async ({ input }) => {
// input is fully typed as z.infer<typeof createPostInput>
// Zod already validated it — safe to use directly
return db.post.create({ data: input });
}),
// Reuse Zod types in your components
type CreatePostInput = z.infer<typeof createPostInput>;
function PostForm() {
const [formData, setFormData] = useState<CreatePostInput>({...});
// TypeScript ensures form state matches the server's expectation
}
Custom Error Handling
tRPC provides a TRPCError class that maps to HTTP status codes automatically. Use it inside procedures to throw meaningful errors that the client receives with full type information:
import { TRPCError } from '@trpc/server';
getPost: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ input }) => {
const post = await db.post.findUnique({ where: { slug: input.slug } });
if (!post) {
throw new TRPCError({
code: 'NOT_FOUND', // → 404
message: `Post "${input.slug}" not found`,
});
}
if (!post.published) {
throw new TRPCError({
code: 'FORBIDDEN', // → 403
message: 'This post is not published',
});
}
return post;
}),
// Available codes: BAD_REQUEST, UNAUTHORIZED, FORBIDDEN,
// NOT_FOUND, CONFLICT, TOO_MANY_REQUESTS, INTERNAL_SERVER_ERROR, etc.
Next.js Integration
Connecting tRPC to Next.js requires an API route handler that receives all tRPC calls, plus a client-side provider that wraps your app. The setup is a one-time investment that pays dividends on every procedure you write afterward.
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '~/server/routers/app';
import { createContext } from '~/server/context';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createContext({ req }),
});
export { handler as GET, handler as POST };
import { createTRPCReact } from '@trpc/react-query';
import { type AppRouter } from '~/server/routers/app';
// This is the typed client — import trpc from this file everywhere
export const trpc = createTRPCReact<AppRouter>();
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from '~/utils/trpc';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
// Optional: add auth headers
headers: () => ({
authorization: getAuthToken() ?? '',
}),
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
tRPC vs REST vs GraphQL
Choosing between tRPC, REST, and GraphQL depends primarily on your stack and whether you need a public API. Here's a practical comparison for TypeScript developers:
| Feature | tRPC | REST | GraphQL |
|---|---|---|---|
| Type Safety | Automatic (TypeScript inference) | Manual or via OpenAPI codegen | Via schema + codegen (graphql-codegen) |
| Schema Language | None — TypeScript is the schema | OpenAPI / JSON Schema (optional) | SDL (required) |
| Code Generation | Not needed | Optional | Usually required for type safety |
| Public API Support | No (TypeScript-only clients) | Yes (any language) | Yes (any language) |
| Over-fetching | Controlled per procedure | Common | None (client specifies fields) |
| Real-time | Subscriptions (WebSocket) | SSE / WebSocket (manual) | Subscriptions (WebSocket) |
| Learning Curve | Low (TypeScript + functions) | Very low | High (SDL, resolvers, N+1 problem) |
| Best For | TypeScript monorepos | Public APIs, polyglot teams | Complex data graphs, multiple clients |
tRPC requires both client and server to be TypeScript. If you need a public API consumed by mobile apps, third parties, or non-TypeScript clients — use REST or GraphQL instead. tRPC is the right tool when you own both ends of the wire.
Advanced Patterns
Context and Authentication
Context is how you pass request-scoped data — like the current user's session — into your procedures. It's created per-request and injected automatically by tRPC:
import { type inferAsyncReturnType } from '@trpc/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '~/server/auth';
import { db } from '~/server/db';
export async function createContext(opts: { req: Request }) {
const session = await getServerSession(authOptions);
return {
session,
user: session?.user ?? null,
db,
};
}
// Export the type so tRPC can infer it
export type Context = inferAsyncReturnType<typeof createContext>;
Procedure Chaining and Middleware
Middleware lets you add cross-cutting concerns like logging, rate limiting, or role checks without repeating code in every procedure:
// Timing middleware — log slow procedures
const timingMiddleware = middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
if (duration > 500) {
console.warn(`Slow procedure: ${type} ${path} took ${duration}ms`);
}
return result;
});
// Role-based access
const isAdmin = middleware(({ ctx, next }) => {
if (ctx.user?.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admins only' });
}
return next({ ctx });
});
// Chain middleware on a procedure
export const adminProcedure = t.procedure
.use(timingMiddleware)
.use(isAuthed)
.use(isAdmin);
Optimistic Updates
React Query's optimistic update pattern works seamlessly with tRPC, giving your UI instant feedback before the server confirms the change:
function LikeButton({ postId }: { postId: string }) {
const utils = trpc.useUtils();
const likeMutation = trpc.posts.like.useMutation({
// Optimistically update the UI before server responds
onMutate: async ({ postId }) => {
await utils.posts.getById.cancel({ id: postId });
const prev = utils.posts.getById.getData({ id: postId });
utils.posts.getById.setData({ id: postId }, old =>
old ? { ...old, likeCount: old.likeCount + 1, liked: true } : old
);
return { prev };
},
// Roll back on error
onError: (err, vars, context) => {
utils.posts.getById.setData({ id: postId }, context?.prev);
},
// Always refetch after mutation settles
onSettled: () => {
utils.posts.getById.invalidate({ id: postId });
},
});
return (
<button onClick={() => likeMutation.mutate({ postId })}>
Like
</button>
);
}
Optimistic update flow: UI updates instantly, server confirms asynchronously
Key Takeaways and Conclusion
tRPC represents a fundamental shift in how TypeScript full-stack developers think about APIs. By leveraging TypeScript's type system as the contract between server and client, it eliminates an entire category of bugs — the mismatch between what the server actually returns and what the client code assumes it returns. The developer experience improvements compound over time: every new procedure you add is instantly type-safe, every input is validated, and every refactor is caught at compile time rather than in production.
When to Choose tRPC
- TypeScript on both ends: You control both the server and client, and both use TypeScript
- Monorepo structure: Frontend and backend share a single repository (Next.js, Turborepo)
- Rapid iteration: You're changing the API often and need compile-time safety to catch regressions
- Small to medium teams: The "no schema" approach is a feature, not a limitation, for teams that move fast
- React Query users: The tRPC + React Query combination is one of the best data-fetching experiences available
"tRPC makes me feel like I'm calling local functions, not crossing a network boundary. The types just work, and that's the point."
— Alex Johansson, Creator of tRPC
Start with a simple Next.js project, add tRPC alongside your existing code, and migrate one endpoint at a time. The types propagate immediately, the React Query integration handles caching for free, and Zod validation gives you runtime safety at the boundary. Once you've experienced building an API where renaming a field on the server instantly shows a type error in your component, it's hard to go back.