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.
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: