tRPC Complete Guide | End-to-End TypeScript Type Safety
이 글의 핵심
tRPC enables end-to-end type safety between TypeScript client and server without code generation. It provides automatic type inference and excellent DX for full-stack TypeScript apps.
Introduction
tRPC allows you to build fully type-safe APIs without schemas or code generation. It leverages TypeScript’s type inference to provide autocompletion and type safety from server to client.
Created by Alex Johansson (@alexdotjs), tRPC has rapidly become the standard for full-stack TypeScript applications. The core innovation is automatic type sharing ??your server types flow directly to the client with zero configuration.
Why tRPC Matters
Traditional full-stack TypeScript pain:
1. Write server types
2. Write client types (manually duplicate)
3. Keep them in sync manually
4. Types drift ??runtime errors
With tRPC:
1. Write server procedures
2. Import trpc client
3. Types automatically inferred
4. Impossible to call wrong API or use wrong types
Real-world adoption:
- create-t3-app (30k+ stars) uses tRPC by default ??one of the most popular Next.js starters
- Cal.com (25k+ stars) ??open-source scheduling, built entirely on tRPC
- Ping.gg ??real-time chat app with tRPC + WebSockets
- Thousands of SaaS products, internal tools, and startups
When to use tRPC:
- Full TypeScript stack (Next.js, Remix, Solid Start)
- Internal APIs (not public REST APIs consumed by non-TS clients)
- Rapid iteration ??change server types, client updates instantly
- Small-to-medium teams where everyone uses TypeScript
When NOT to use tRPC:
- Public API for mobile apps, third-party devs (use REST + OpenAPI)
- Polyglot backends (Java, Python, Go) ??tRPC is TypeScript-only
- GraphQL’s flexibility is required (complex data fetching patterns)
Traditional API
// Server
app.post('/api/user', (req, res) => {
const { name, email } = req.body;
// No type safety!
const user = db.createUser({ name, email });
res.json(user);
});
// Client
const res = await fetch('/api/user', {
method: 'POST',
body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }),
});
const user = await res.json(); // any type!
With tRPC
// Server
const appRouter = router({
user: {
create: publicProcedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(({ input }) => {
return db.createUser(input); // Fully typed!
}),
},
});
// Client
const user = await trpc.user.create.mutate({
name: 'Alice',
email: 'alice@example.com',
}); // Fully typed!
1. Installation
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
2. Server Setup
// server/trpc.ts
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
export const userRouter = router({
getById: publicProcedure
.input(z.number())
.query(async ({ input }) => {
return await db.user.findUnique({ where: { id: input } });
}),
list: publicProcedure.query(async () => {
return await db.user.findMany();
}),
create: publicProcedure
.input(z.object({
name: z.string(),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return await db.user.create({ data: input });
}),
});
// server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
export const appRouter = router({
user: userRouter,
});
export type AppRouter = typeof appRouter;
3. Server Integration (Next.js)
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
4. Client Setup
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/utils/trpc';
import { useState } from 'react';
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
// app/layout.tsx
import { TRPCProvider } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}
5. Client Usage
'use client';
import { trpc } from '@/utils/trpc';
export function UserList() {
const { data: users, isLoading } = trpc.user.list.useQuery();
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
export function UserProfile({ userId }: { userId: number }) {
const { data: user } = trpc.user.getById.useQuery(userId);
return <div>{user?.name}</div>;
}
export function CreateUserForm() {
const createUser = trpc.user.create.useMutation();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await createUser.mutateAsync({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Create</button>
</form>
);
}
6. Context and Authentication
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';
export const createContext = async (opts: FetchCreateContextFnOptions) => {
const token = opts.req.headers.get('authorization');
const user = await getUserFromToken(token);
return { user };
};
type Context = Awaited<ReturnType<typeof createContext>>;
const t = initTRPC.context<Context>().create();
// Public procedure (no auth)
export const publicProcedure = t.procedure;
// Protected procedure (requires auth)
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
user: ctx.user, // Now guaranteed to be defined
},
});
});
// Usage
export const postRouter = router({
// Anyone can read
list: publicProcedure.query(async () => {
return await db.post.findMany();
}),
// Must be logged in to create
create: protectedProcedure
.input(z.object({ title: z.string(), content: z.string() }))
.mutation(async ({ ctx, input }) => {
return await db.post.create({
data: {
...input,
authorId: ctx.user.id, // ctx.user is guaranteed
},
});
}),
});
7. Error Handling
import { TRPCError } from '@trpc/server';
export const userRouter = router({
getById: publicProcedure
.input(z.number())
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input } });
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
});
}
return user;
}),
});
// Client error handling
function UserProfile({ userId }: { userId: number }) {
const { data: user, error } = trpc.user.getById.useQuery(userId);
if (error) {
return <div>Error: {error.message}</div>;
}
return <div>{user?.name}</div>;
}
8. Batching
// Client automatically batches requests
const [user1, user2, user3] = await Promise.all([
trpc.user.getById.query(1),
trpc.user.getById.query(2),
trpc.user.getById.query(3),
]);
// Single HTTP request with all 3 queries!
9. Subscriptions (WebSockets)
// Server
import { observable } from '@trpc/server/observable';
export const messageRouter = router({
onMessage: publicProcedure.subscription(() => {
return observable<{ id: number; text: string }>((emit) => {
const onMessage = (msg: Message) => {
emit.next(msg);
};
eventEmitter.on('message', onMessage);
return () => {
eventEmitter.off('message', onMessage);
};
});
}),
});
// Client
function MessageList() {
trpc.message.onMessage.useSubscription(undefined, {
onData(message) {
console.log('New message:', message);
},
});
return <div>Messages</div>;
}
10. Real-World Example: Blog API
// server/routers/blog.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
export const blogRouter = router({
// List posts with pagination
list: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.number().optional(),
})
)
.query(async ({ input }) => {
const posts = await db.post.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
let nextCursor: number | undefined;
if (posts.length > input.limit) {
const nextItem = posts.pop();
nextCursor = nextItem?.id;
}
return {
posts,
nextCursor,
};
}),
// Get single post
getById: publicProcedure
.input(z.number())
.query(async ({ input }) => {
const post = await db.post.findUnique({
where: { id: input },
include: { author: true, comments: true },
});
if (!post) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return post;
}),
// Create post (auth required)
create: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
return await db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
// Update post (auth + ownership check)
update: protectedProcedure
.input(
z.object({
id: z.number(),
title: z.string().optional(),
content: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const post = await db.post.findUnique({ where: { id: input.id } });
if (post?.authorId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return await db.post.update({
where: { id: input.id },
data: input,
});
}),
// Delete post
delete: protectedProcedure
.input(z.number())
.mutation(async ({ ctx, input }) => {
const post = await db.post.findUnique({ where: { id: input } });
if (post?.authorId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
await db.post.delete({ where: { id: input } });
return { success: true };
}),
});
11. Best Practices
1. Use Zod for Validation
const createUserInput = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().min(0).max(150).optional(),
});
export const userRouter = router({
create: publicProcedure
.input(createUserInput)
.mutation(({ input }) => {
// input is fully typed and validated!
}),
});
2. Separate Routers by Domain
server/routers/
—�?— _app.ts # Main router
—�?— user.ts # User operations
—�?— post.ts # Post operations
—�?— comment.ts # Comment operations
3. Use Context for Dependencies
export const createContext = async () => {
return {
db: prisma,
redis: redisClient,
logger: winston,
};
};
Summary
tRPC provides end-to-end type safety:
- No code generation - types inferred automatically
- Full TypeScript integration
- Excellent DX - autocomplete everywhere
- Built on React Query for caching
- WebSocket support for real-time
Key Takeaways:
- End-to-end type safety without codegen
- Use Zod for input validation
- Context for auth and dependencies
- Batching for performance
- Works great with Next.js
Next Steps:
- Build with Next.js 15
- Validate with Zod
- Query with TanStack Query
Resources:
Frequently Asked Questions (FAQ)
Q. When would I use this in practice?
A. tRPC enables end-to-end type safety between TypeScript client and server without code generation. It provides automatic type inference and excellent DX for full-stack TypeScript apps.
Q. What should I read before this?
A. Follow the previous article or related articles links at the bottom of each post to learn in sequence.
Q. Where can I study this more deeply?
A. Check cppreference and the relevant library’s official documentation. The reference links at the end of the article are also worth using.
Related Articles (Internal Links)
Other articles related to this topic.
- Zod Complete Guide: TypeScript-First Schema Validation Library
- GraphQL Complete Guide | Schema· Resolver
Keywords Covered in This Article (Related Search Terms)
This article covers tRPC, TypeScript, Web-API, React, Next.js, Full Stack.