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: '[email protected]' }),
});
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: '[email protected]',
}); // 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](/en/blog/nextjs-15-complete-guide/
- Validate with [Zod](/en/blog/zod-complete-guide/
- Query with [TanStack Query](/en/blog/tanstack-query-complete-guide/
Resources:
?�주 묻는 질문 (FAQ)
Q. ???�용???�무?�서 ?�제 ?�나??
A. Complete tRPC guide for type-safe APIs. Learn routers, procedures, React integration, authentication, and building full-???�무?�서????본문???�제?� ?�택 가?�드�?참고???�용?�면 ?�니??
Q. ?�행?�로 ?�으�?좋�? 글?�?
A. �?글 ?�단???�전 글 ?�는 관??글 링크�??�라가�??�서?��?배울 ???�습?�다. C++ ?�리�?목차?�서 ?�체 ?�름???�인?????�습?�다.
Q. ??깊이 공�??�려�?
A. cppreference?� ?�당 ?�이브러�?공식 문서�?참고?�세?? 글 말�???참고 ?�료 링크???�용?�면 좋습?�다.
같이 보면 좋�? 글 (?��? 링크)
??주제?� ?�결?�는 ?�른 글?�니??
- The Complete tRPC Guide | End-to-End Type Safety, API, React Query, and Production
- The Complete Zod Guide | TypeScript Schema Validation, Type Safety, Forms, APIs, and Production Use
- [GraphQL Complete Guide | Schema· Resolver](/en/blog/graphql-complete-guide/
??글?�서 ?�루???�워??(관??검?�어)
tRPC, TypeScript, API, React, Next.js, Full Stack ?�으�?검?�하?�면 ??글???��????�니??