trpc-type-safety
npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill trpc-type-safety
Agent 安装分布
Skill 文档
tRPC – End-to-End Type Safety
progressive_disclosure: entry_point: summary sections: – id: summary title: “tRPC Overview” tokens: 70 next: [when_to_use, quick_start] – id: when_to_use title: “When to Use tRPC” tokens: 150 next: [quick_start, core_concepts] – id: quick_start title: “Quick Start” tokens: 300 next: [core_concepts, router_definition] – id: core_concepts title: “Core Concepts” tokens: 400 next: [router_definition, procedures] – id: router_definition title: “Router Definition” tokens: 350 next: [procedures, context] – id: procedures title: “Procedures (Query & Mutation)” tokens: 400 next: [input_validation, context] – id: input_validation title: “Input Validation with Zod” tokens: 350 next: [context, middleware] – id: context title: “Context Management” tokens: 400 next: [middleware, error_handling] – id: middleware title: “Middleware” tokens: 400 next: [error_handling, client_setup] – id: error_handling title: “Error Handling” tokens: 350 next: [client_setup, react_integration] – id: client_setup title: “Client Setup” tokens: 400 next: [react_integration, nextjs_integration] – id: react_integration title: “React Query Integration” tokens: 450 next: [nextjs_integration, subscriptions] – id: nextjs_integration title: “Next.js App Router Integration” tokens: 500 next: [subscriptions, file_uploads] – id: subscriptions title: “Real-time Subscriptions” tokens: 400 next: [file_uploads, batching] – id: file_uploads title: “File Uploads” tokens: 300 next: [batching, typescript_inference] – id: batching title: “Batch Requests & Data Loaders” tokens: 350 next: [typescript_inference, testing] – id: typescript_inference title: “TypeScript Inference Patterns” tokens: 300 next: [testing, production_patterns] – id: testing title: “Testing Strategies” tokens: 400 next: [production_patterns, comparison] – id: production_patterns title: “Production Patterns” tokens: 450 next: [comparison, migration] – id: comparison title: “Comparison with REST & GraphQL” tokens: 250 next: [migration, best_practices] – id: migration title: “Migration from REST” tokens: 300 next: [best_practices] – id: best_practices title: “Best Practices & Performance” tokens: 400
Summary
tRPC enables end-to-end type safety between TypeScript clients and servers without code generation. Define your API once, get automatic type inference everywhere.
Key Benefits: Zero codegen, TypeScript inference, React Query integration, minimal boilerplate.
When to Use tRPC
â Perfect For:
- Full-stack TypeScript applications (Next.js, T3 stack)
- Projects where client and server share TypeScript codebase
- Teams wanting REST-like simplicity with GraphQL-like type safety
- Apps using React Query for data fetching
- Internal APIs where you control both client and server
â Avoid When:
- Public APIs consumed by non-TypeScript clients
- Microservices in different languages
- Mobile apps using Swift/Kotlin (use REST/GraphQL instead)
- Need API documentation for external developers (OpenAPI better)
When to Choose:
- tRPC: Full-stack TypeScript, monorepo, internal tools
- REST: Public APIs, language-agnostic, broad compatibility
- GraphQL: Complex data graphs, multiple clients, flexible queries
Quick Start
Installation
# Server dependencies
npm install @trpc/server zod
# React/Next.js client dependencies
npm install @trpc/client @trpc/react-query @tanstack/react-query
Define Router (Server)
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
hello: t.procedure
.input(z.object({ name: z.string() }))
.query(({ input }) => {
return { greeting: `Hello ${input.name}` };
}),
createPost: t.procedure
.input(z.object({ title: z.string(), content: z.string() }))
.mutation(async ({ input }) => {
// Save to database
return { id: 1, ...input };
}),
});
export type AppRouter = typeof appRouter;
Use in Client (React)
// client/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/trpc';
export const trpc = createTRPCReact<AppRouter>();
// Component
function MyComponent() {
const { data } = trpc.hello.useQuery({ name: 'World' });
const createPost = trpc.createPost.useMutation();
return <div>{data?.greeting}</div>; // Fully typed!
}
Next: Learn core concepts or dive into router definition.
Core Concepts
The tRPC Philosophy
tRPC provides type-safe remote procedure calls by sharing TypeScript types between client and server. No code generationâjust TypeScript’s inference.
Key Components
- Router: Collection of procedures (API endpoints)
- Procedure: Single API operation (query or mutation)
- Context: Request-scoped data (user, database, etc.)
- Middleware: Intercept/modify requests (auth, logging)
- Input/Output: Validated with Zod schemas
Type Flow
// Server defines types
const router = t.router({
getUser: t.procedure
.input(z.string())
.query(({ input }) => ({ id: input, name: 'Alice' })),
});
// Client gets automatic types
const user = await trpc.getUser.query('123');
// user is typed as { id: string, name: string }
Architecture Pattern
âââââââââââââââ Type-safe ââââââââââââââââ
â Client â ââââââââââââââââââ â Server â
â (React) â No codegen! â (Node.js) â
âââââââââââââââ ââââââââââââââââ
â â
React Query tRPC Router
(caching) (procedures)
Advantages:
- Changes propagate instantly (no build step)
- Rename refactoring works across client/server
- Impossible to call wrong types
- Auto-complete for all API methods
Router Definition
Basic Router Structure
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const appRouter = t.router({
// Procedures go here
});
export type AppRouter = typeof appRouter;
Nested Routers (Namespacing)
const userRouter = t.router({
getById: t.procedure
.input(z.string())
.query(({ input }) => getUser(input)),
create: t.procedure
.input(z.object({ name: z.string(), email: z.string() }))
.mutation(({ input }) => createUser(input)),
});
const postRouter = t.router({
list: t.procedure.query(() => getPosts()),
create: t.procedure
.input(z.object({ title: z.string() }))
.mutation(({ input }) => createPost(input)),
});
export const appRouter = t.router({
user: userRouter,
post: postRouter,
});
// Client usage:
// trpc.user.getById.useQuery('123')
// trpc.post.list.useQuery()
Router Merging
import { adminRouter } from './admin';
import { publicRouter } from './public';
export const appRouter = t.mergeRouters(publicRouter, adminRouter);
Router Organization Best Practices
server/
âââ trpc.ts # tRPC instance, context, middleware
âââ routers/
â âââ user.ts # User-related procedures
â âââ post.ts # Post-related procedures
â âââ index.ts # Combine all routers
âââ index.ts # Export AppRouter type
Procedures (Query & Mutation)
Query Procedures (Read Operations)
const router = t.router({
// Simple query
getUser: t.procedure
.input(z.string())
.query(({ input }) => {
return db.user.findUnique({ where: { id: input } });
}),
// Query with multiple inputs
searchUsers: t.procedure
.input(z.object({
query: z.string(),
limit: z.number().default(10),
}))
.query(({ input }) => {
return db.user.findMany({
where: { name: { contains: input.query } },
take: input.limit,
});
}),
});
Mutation Procedures (Write Operations)
const router = t.router({
createUser: t.procedure
.input(z.object({
name: z.string().min(3),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return await db.user.create({ data: input });
}),
updateUser: t.procedure
.input(z.object({
id: z.string(),
data: z.object({
name: z.string().optional(),
email: z.string().email().optional(),
}),
}))
.mutation(async ({ input }) => {
return await db.user.update({
where: { id: input.id },
data: input.data,
});
}),
});
Query vs Mutation
| Aspect | Query | Mutation |
|---|---|---|
| Purpose | Read data | Modify data |
| HTTP Method | GET | POST |
| Caching | Cached by React Query | Not cached |
| Idempotent | Yes | No |
| Side Effects | None | Database writes, emails, etc. |
Output Typing
const router = t.router({
getUser: t.procedure
.input(z.string())
.output(z.object({ id: z.string(), name: z.string() })) // Optional
.query(({ input }) => {
return { id: input, name: 'Alice' };
}),
});
Note: Output validation adds runtime overheadâuse for critical data only.
Input Validation with Zod
Why Zod?
tRPC uses Zod for runtime type validation and TypeScript inference. Zod schemas provide:
- Runtime validation (prevent invalid data)
- TypeScript types (auto-inferred from schema)
- Transformation (parse, coerce, default values)
Basic Validation
import { z } from 'zod';
const router = t.router({
createPost: t.procedure
.input(z.object({
title: z.string().min(5).max(100),
content: z.string(),
published: z.boolean().default(false),
tags: z.array(z.string()).optional(),
}))
.mutation(({ input }) => {
// input is fully typed and validated
return createPost(input);
}),
});
Advanced Validation
const createUserInput = z.object({
email: z.string().email(),
password: z.string().min(8),
age: z.number().int().min(18),
role: z.enum(['user', 'admin']),
metadata: z.record(z.string(), z.unknown()).optional(),
});
const router = t.router({
createUser: t.procedure
.input(createUserInput)
.mutation(({ input }) => {
// All validation passed
return saveUser(input);
}),
});
Transformations
const router = t.router({
getUser: t.procedure
.input(
z.object({
id: z.string().transform((id) => parseInt(id, 10)),
})
)
.query(({ input }) => {
// input.id is now a number
return db.user.findUnique({ where: { id: input.id } });
}),
});
Reusable Schemas
// schemas/user.ts
export const CreateUserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
export const UpdateUserSchema = CreateUserSchema.partial().extend({
id: z.string(),
});
// routers/user.ts
const router = t.router({
create: t.procedure.input(CreateUserSchema).mutation(/*...*/),
update: t.procedure.input(UpdateUserSchema).mutation(/*...*/),
});
Context Management
What is Context?
Context provides request-scoped data to all proceduresâauthentication, database connections, logging, etc.
Creating Context
import { inferAsyncReturnType } from '@trpc/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
export async function createContext(opts: CreateNextContextOptions) {
const session = await getSession(opts.req);
return {
session,
db: prisma,
req: opts.req,
res: opts.res,
};
}
export type Context = inferAsyncReturnType<typeof createContext>;
const t = initTRPC.context<Context>().create();
Using Context in Procedures
const router = t.router({
getMe: t.procedure.query(({ ctx }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return ctx.db.user.findUnique({
where: { id: ctx.session.user.id },
});
}),
createPost: t.procedure
.input(z.object({ title: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
data: {
title: input.title,
authorId: ctx.session.user.id,
},
});
}),
});
Context Best Practices
// â
Good: Lazy database connection
export async function createContext(opts: CreateNextContextOptions) {
return {
getDB: () => prisma, // Lazy
session: await getSession(opts.req),
};
}
// â Bad: Heavy computation in context
export async function createContext(opts: CreateNextContextOptions) {
const allUsers = await prisma.user.findMany(); // Too expensive!
return { allUsers };
}
Middleware
What is Middleware?
Middleware intercepts procedure calls to add cross-cutting concerns: logging, timing, authentication, rate limiting.
Basic Middleware
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
console.log(`â ${type} ${path}`);
const result = await next();
const duration = Date.now() - start;
console.log(`â ${type} ${path} - ${duration}ms`);
return result;
});
const loggedProcedure = t.procedure.use(loggerMiddleware);
Authentication Middleware
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
...ctx,
user: ctx.session.user, // Narrow type
},
});
});
// Protected procedure builder
const protectedProcedure = t.procedure.use(isAuthed);
const router = t.router({
// Public
getPublicPosts: t.procedure.query(() => getPosts()),
// Protected - requires authentication
getMyPosts: protectedProcedure.query(({ ctx }) => {
// ctx.user is guaranteed to exist
return getPostsByUser(ctx.user.id);
}),
});
Chaining Middleware
const timingMiddleware = t.middleware(async ({ next }) => {
const start = performance.now();
const result = await next();
console.log(`Execution time: ${performance.now() - start}ms`);
return result;
});
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
await checkRateLimit(ctx.session?.user?.id);
return next();
});
const protectedProcedure = t.procedure
.use(timingMiddleware)
.use(rateLimitMiddleware)
.use(isAuthed);
Context Transformation
const enrichContextMiddleware = t.middleware(async ({ ctx, next }) => {
const user = ctx.session?.user
? await ctx.db.user.findUnique({ where: { id: ctx.session.user.id } })
: null;
return next({
ctx: {
...ctx,
user, // Full user object
},
});
});
Error Handling
TRPCError
import { TRPCError } from '@trpc/server';
const router = t.router({
getUser: t.procedure
.input(z.string())
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input } });
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `User ${input} not found`,
});
}
return user;
}),
});
Error Codes
| Code | HTTP Status | Use Case |
|---|---|---|
BAD_REQUEST |
400 | Invalid input |
UNAUTHORIZED |
401 | Not authenticated |
FORBIDDEN |
403 | Not authorized |
NOT_FOUND |
404 | Resource not found |
TIMEOUT |
408 | Request timeout |
CONFLICT |
409 | Resource conflict |
PRECONDITION_FAILED |
412 | Precondition failed |
PAYLOAD_TOO_LARGE |
413 | Request too large |
TOO_MANY_REQUESTS |
429 | Rate limit exceeded |
CLIENT_CLOSED_REQUEST |
499 | Client closed connection |
INTERNAL_SERVER_ERROR |
500 | Server error |
Custom Error Handling
const router = t.router({
deleteUser: t.procedure
.input(z.string())
.mutation(async ({ input, ctx }) => {
try {
return await ctx.db.user.delete({ where: { id: input } });
} catch (error) {
if (error.code === 'P2025') { // Prisma not found
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
cause: error,
});
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to delete user',
cause: error,
});
}
}),
});
Error Formatting
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
Client-Side Error Handling
function MyComponent() {
const mutation = trpc.createUser.useMutation({
onError: (error) => {
if (error.data?.code === 'UNAUTHORIZED') {
router.push('/login');
} else {
toast.error(error.message);
}
},
});
}
Client Setup
Vanilla Client
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server';
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
}),
],
});
// Usage
const user = await client.user.getById.query('123');
const newPost = await client.post.create.mutate({ title: 'Hello' });
React Client Setup
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/routers';
export const trpc = createTRPCReact<AppRouter>();
// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from '../utils/trpc';
export default function App({ Component, pageProps }: AppProps) {
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}>
<Component {...pageProps} />
</QueryClientProvider>
</trpc.Provider>
);
}
Next.js API Route
// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers';
import { createContext } from '../../../server/context';
export default createNextApiHandler({
router: appRouter,
createContext,
});
Headers & Authentication
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
headers: async () => {
const token = await getAuthToken();
return {
authorization: token ? `Bearer ${token}` : undefined,
};
},
}),
],
});
React Query Integration
useQuery Hook
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = trpc.user.getById.useQuery(userId);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{data.name}</div>;
}
Query Options
const { data } = trpc.posts.list.useQuery(undefined, {
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
retry: 3,
onSuccess: (data) => console.log('Fetched', data.length, 'posts'),
});
useMutation Hook
function CreatePostForm() {
const utils = trpc.useContext();
const createPost = trpc.post.create.useMutation({
onSuccess: () => {
// Invalidate and refetch
utils.post.list.invalidate();
},
});
const handleSubmit = (data: { title: string }) => {
createPost.mutate(data);
};
return (
<form onSubmit={handleSubmit}>
<input name="title" />
<button disabled={createPost.isLoading}>
{createPost.isLoading ? 'Creating...' : 'Create'}
</button>
{createPost.error && <p>{createPost.error.message}</p>}
</form>
);
}
Optimistic Updates
const createPost = trpc.post.create.useMutation({
onMutate: async (newPost) => {
// Cancel outgoing refetches
await utils.post.list.cancel();
// Snapshot previous value
const previousPosts = utils.post.list.getData();
// Optimistically update
utils.post.list.setData(undefined, (old) => [
...(old ?? []),
{ id: 'temp', ...newPost },
]);
return { previousPosts };
},
onError: (err, newPost, context) => {
// Rollback on error
utils.post.list.setData(undefined, context?.previousPosts);
},
onSettled: () => {
// Refetch after success or error
utils.post.list.invalidate();
},
});
Infinite Queries
// Server
const router = t.router({
posts: t.procedure
.input(z.object({
cursor: z.number().optional(),
limit: z.number().default(10),
}))
.query(({ input }) => {
const posts = getPosts(input.cursor, input.limit);
return {
posts,
nextCursor: posts.length === input.limit ? input.cursor + input.limit : undefined,
};
}),
});
// Client
function PostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = trpc.posts.useInfiniteQuery(
{ limit: 10 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
return (
<div>
{data?.pages.map((page) =>
page.posts.map((post) => <PostCard key={post.id} post={post} />)
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
Load More
</button>
)}
</div>
);
}
Next.js App Router Integration
Server Components
// app/users/page.tsx (Server Component)
import { createCaller } from '../server/routers';
import { createContext } from '../server/context';
export default async function UsersPage() {
const ctx = await createContext({ req: null, res: null });
const caller = createCaller(ctx);
const users = await caller.user.list();
return (
<div>
{users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
Server Actions
// app/actions.ts
'use server';
import { createCaller } from '../server/routers';
import { createContext } from '../server/context';
export async function createPost(formData: FormData) {
const ctx = await createContext({ req: null, res: null });
const caller = createCaller(ctx);
return caller.post.create({
title: formData.get('title') as string,
content: formData.get('content') as string,
});
}
App Router Provider
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from './trpc';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Client Components in App Router
// app/posts/create-button.tsx
'use client';
import { trpc } from '../trpc';
export function CreatePostButton() {
const createPost = trpc.post.create.useMutation();
return (
<button onClick={() => createPost.mutate({ title: 'New Post' })}>
Create Post
</button>
);
}
API Route Handler (App Router)
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '../../../../server/routers';
import { createContext } from '../../../../server/context';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
});
export { handler as GET, handler as POST };
Real-time Subscriptions
WebSocket Setup (Server)
import { applyWSSHandler } from '@trpc/server/adapters/ws';
import ws from 'ws';
const wss = new ws.Server({ port: 3001 });
applyWSSHandler({
wss,
router: appRouter,
createContext,
});
console.log('WebSocket server listening on port 3001');
Subscription Procedure
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';
const ee = new EventEmitter();
const router = t.router({
onPostAdd: t.procedure.subscription(() => {
return observable<Post>((emit) => {
const onAdd = (data: Post) => emit.next(data);
ee.on('add', onAdd);
return () => {
ee.off('add', onAdd);
};
});
}),
createPost: t.procedure
.input(z.object({ title: z.string() }))
.mutation(({ input }) => {
const post = { id: Date.now().toString(), ...input };
ee.emit('add', post); // Emit to subscribers
return post;
}),
});
Client WebSocket Setup
import { createWSClient, wsLink } from '@trpc/client';
const wsClient = createWSClient({
url: 'ws://localhost:3001',
});
const trpcClient = trpc.createClient({
links: [
wsLink({
client: wsClient,
}),
],
});
useSubscription Hook
function PostFeed() {
const [posts, setPosts] = useState<Post[]>([]);
trpc.onPostAdd.useSubscription(undefined, {
onData: (post) => {
setPosts((prev) => [post, ...prev]);
},
onError: (err) => {
console.error('Subscription error:', err);
},
});
return (
<div>
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
Subscription with Input
// Server
const router = t.router({
onUserStatusChange: t.procedure
.input(z.string())
.subscription(({ input }) => {
return observable<UserStatus>((emit) => {
const onChange = (userId: string, status: UserStatus) => {
if (userId === input) {
emit.next(status);
}
};
ee.on('statusChange', onChange);
return () => ee.off('statusChange', onChange);
});
}),
});
// Client
trpc.onUserStatusChange.useSubscription('user-123', {
onData: (status) => console.log('Status:', status),
});
File Uploads
Multipart Form Data (Server)
// Next.js API route with file upload
import { NextApiRequest, NextApiResponse } from 'next';
import formidable from 'formidable';
import fs from 'fs';
export const config = {
api: { bodyParser: false },
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const form = formidable({ multiples: false });
form.parse(req, async (err, fields, files) => {
if (err) return res.status(500).json({ error: 'Upload failed' });
const file = files.file as formidable.File;
const buffer = fs.readFileSync(file.filepath);
// Upload to S3, etc.
const url = await uploadToS3(buffer, file.originalFilename);
res.json({ url });
});
}
Base64 Upload (tRPC)
// For small files only (<1MB)
const router = t.router({
uploadAvatar: t.procedure
.input(z.object({
fileName: z.string(),
fileData: z.string(), // Base64
}))
.mutation(async ({ input }) => {
const buffer = Buffer.from(input.fileData, 'base64');
const url = await uploadToS3(buffer, input.fileName);
return { url };
}),
});
// Client
const uploadAvatar = trpc.uploadAvatar.useMutation();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const base64 = reader.result as string;
uploadAvatar.mutate({
fileName: file.name,
fileData: base64.split(',')[1], // Remove data:image/...;base64,
});
};
reader.readAsDataURL(file);
};
Signed URL Pattern (Recommended)
// Step 1: Get signed upload URL from tRPC
const router = t.router({
getUploadUrl: t.procedure
.input(z.object({
fileName: z.string(),
fileType: z.string(),
}))
.mutation(async ({ input }) => {
const signedUrl = await s3.getSignedUrl('putObject', {
Bucket: 'my-bucket',
Key: input.fileName,
ContentType: input.fileType,
Expires: 60, // 1 minute
});
return { uploadUrl: signedUrl, fileUrl: `https://cdn.example.com/${input.fileName}` };
}),
});
// Step 2: Client uploads directly to S3
async function uploadFile(file: File) {
// Get signed URL
const { uploadUrl, fileUrl } = await trpc.getUploadUrl.mutate({
fileName: file.name,
fileType: file.type,
});
// Upload directly to S3
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
// Save file URL to database via tRPC
await trpc.user.updateAvatar.mutate({ url: fileUrl });
}
Batch Requests & Data Loaders
Automatic Batching
// Client configuration
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
maxBatchSize: 10, // Batch up to 10 requests
}),
],
});
// Multiple calls made close together are batched into one HTTP request
const user1 = trpc.user.getById.useQuery('1');
const user2 = trpc.user.getById.useQuery('2');
const user3 = trpc.user.getById.useQuery('3');
// â Single HTTP request with 3 procedure calls
DataLoader Pattern
import DataLoader from 'dataloader';
// Create DataLoader in context
export async function createContext() {
const userLoader = new DataLoader(async (ids: readonly string[]) => {
const users = await db.user.findMany({
where: { id: { in: [...ids] } },
});
// Return in same order as input
return ids.map((id) => users.find((u) => u.id === id));
});
return { userLoader };
}
// Use in procedures
const router = t.router({
getUser: t.procedure
.input(z.string())
.query(({ ctx, input }) => {
return ctx.userLoader.load(input); // Batched!
}),
getPosts: t.procedure.query(async ({ ctx }) => {
const posts = await db.post.findMany({ take: 10 });
// N+1 problem solvedâall authors fetched in one query
const postsWithAuthors = await Promise.all(
posts.map(async (post) => ({
...post,
author: await ctx.userLoader.load(post.authorId),
}))
);
return postsWithAuthors;
}),
});
Conditional Batching
import { httpBatchLink, httpLink, splitLink } from '@trpc/client';
const trpcClient = trpc.createClient({
links: [
splitLink({
// Batch queries, don't batch mutations
condition: (op) => op.type === 'query',
true: httpBatchLink({ url: '/api/trpc' }),
false: httpLink({ url: '/api/trpc' }),
}),
],
});
TypeScript Inference Patterns
Inferring Types from Router
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from './server';
// Input types
type RouterInputs = inferRouterInputs<AppRouter>;
type CreateUserInput = RouterInputs['user']['create'];
// Output types
type RouterOutputs = inferRouterOutputs<AppRouter>;
type User = RouterOutputs['user']['getById'];
// Use in components
function UserCard({ user }: { user: User }) {
return <div>{user.name}</div>;
}
Procedure Helpers
import type { inferProcedureInput, inferProcedureOutput } from '@trpc/server';
type CreatePostInput = inferProcedureInput<AppRouter['post']['create']>;
type Post = inferProcedureOutput<AppRouter['post']['getById']>;
Context Type Inference
import { inferAsyncReturnType } from '@trpc/server';
export async function createContext() {
return {
db: prisma,
user: null as User | null,
};
}
export type Context = inferAsyncReturnType<typeof createContext>;
const t = initTRPC.context<Context>().create();
Generic Procedures
// Reusable pagination
function createPaginatedProcedure<T>(
getData: (cursor: number, limit: number) => Promise<T[]>
) {
return t.procedure
.input(z.object({
cursor: z.number().optional(),
limit: z.number().default(10),
}))
.query(async ({ input }) => {
const items = await getData(input.cursor ?? 0, input.limit);
return {
items,
nextCursor: items.length === input.limit
? (input.cursor ?? 0) + input.limit
: undefined,
};
});
}
const router = t.router({
posts: createPaginatedProcedure((cursor, limit) =>
db.post.findMany({ skip: cursor, take: limit })
),
users: createPaginatedProcedure((cursor, limit) =>
db.user.findMany({ skip: cursor, take: limit })
),
});
Testing Strategies
Unit Testing Procedures
import { createCaller } from '../routers';
describe('User Router', () => {
it('should create user', async () => {
const ctx = {
db: mockDb,
session: null,
};
const caller = createCaller(ctx);
const result = await caller.user.create({
name: 'Alice',
email: 'alice@example.com',
});
expect(result).toMatchObject({
name: 'Alice',
email: 'alice@example.com',
});
});
});
Integration Testing
import { httpBatchLink } from '@trpc/client';
import { createTRPCProxyClient } from '@trpc/client';
import type { AppRouter } from '../server';
describe('tRPC Integration', () => {
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
}),
],
});
it('should fetch user', async () => {
const user = await client.user.getById.query('123');
expect(user.id).toBe('123');
});
});
Mocking Context
import { createCaller } from '../routers';
const mockContext = {
db: {
user: {
findUnique: vi.fn().mockResolvedValue({ id: '1', name: 'Alice' }),
create: vi.fn(),
},
},
session: {
user: { id: '1', email: 'alice@example.com' },
},
};
it('should get current user', async () => {
const caller = createCaller(mockContext);
const user = await caller.user.getMe();
expect(mockContext.db.user.findUnique).toHaveBeenCalledWith({
where: { id: '1' },
});
expect(user.name).toBe('Alice');
});
Testing React Hooks
import { renderHook, waitFor } from '@testing-library/react';
import { createWrapper } from './test-utils';
it('should fetch posts', async () => {
const { result } = renderHook(() => trpc.post.list.useQuery(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toHaveLength(10);
});
// test-utils.ts
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '../utils/trpc';
export function createWrapper() {
const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
links: [httpBatchLink({ url: 'http://localhost:3000/api/trpc' })],
});
return ({ children }) => (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
Production Patterns
Error Monitoring
import * as Sentry from '@sentry/node';
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
// Log to Sentry
if (error.code === 'INTERNAL_SERVER_ERROR') {
Sentry.captureException(error);
}
return {
...shape,
data: {
...shape.data,
// Don't expose internal errors in production
message: process.env.NODE_ENV === 'production' && error.code === 'INTERNAL_SERVER_ERROR'
? 'Internal server error'
: shape.message,
},
};
},
});
Rate Limiting
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'),
});
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
const identifier = ctx.session?.user?.id ?? ctx.req.ip;
const { success } = await ratelimit.limit(identifier);
if (!success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded',
});
}
return next();
});
Caching
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const router = t.router({
getUser: t.procedure
.input(z.string())
.query(async ({ input }) => {
// Check cache
const cached = await redis.get(`user:${input}`);
if (cached) return JSON.parse(cached);
// Fetch from database
const user = await db.user.findUnique({ where: { id: input } });
// Cache for 5 minutes
await redis.setex(`user:${input}`, 300, JSON.stringify(user));
return user;
}),
});
Request Logging
const loggingMiddleware = t.middleware(async ({ path, type, next, input }) => {
const start = Date.now();
console.log(`â ${type} ${path}`, { input });
try {
const result = await next();
const duration = Date.now() - start;
console.log(`â ${type} ${path} - ${duration}ms`);
return result;
} catch (error) {
const duration = Date.now() - start;
console.error(`â ${type} ${path} - ${duration}ms`, { error });
throw error;
}
});
OpenTelemetry Integration
import { trace } from '@opentelemetry/api';
const tracingMiddleware = t.middleware(async ({ path, type, next }) => {
const tracer = trace.getTracer('trpc');
return tracer.startActiveSpan(`trpc.${type}.${path}`, async (span) => {
try {
const result = await next();
span.setStatus({ code: 0 }); // OK
return result;
} catch (error) {
span.setStatus({ code: 2, message: error.message }); // ERROR
span.recordException(error);
throw error;
} finally {
span.end();
}
});
});
Comparison with REST & GraphQL
Feature Comparison
| Feature | tRPC | REST | GraphQL |
|---|---|---|---|
| Type Safety | Full (TypeScript) | Manual/codegen | Manual/codegen |
| Code Generation | None | Optional (OpenAPI) | Required |
| Learning Curve | Low | Low | Medium/High |
| Client Libraries | TypeScript only | Any language | Any language |
| API Documentation | TypeScript types | OpenAPI/Swagger | Schema/introspection |
| Public APIs | â No | â Yes | â Yes |
| Flexible Queries | â Fixed | â Fixed | â Yes |
| Overfetching | Minimal | Common | None |
| Caching | React Query | HTTP caching | Complex |
| Real-time | WebSocket | SSE/WebSocket | Subscriptions |
| File Uploads | Workarounds | Native | Complex |
When to Choose Each
tRPC:
- â Full-stack TypeScript monorepo
- â Internal tools and dashboards
- â Next.js applications
- â Rapid development with small teams
- â Public APIs for external consumers
- â Multi-language clients
REST:
- â Public APIs with broad compatibility
- â Multi-language services
- â HTTP caching requirements
- â File uploads and downloads
- â Complex nested data structures
- â Need for type safety without codegen
GraphQL:
- â Complex data graphs
- â Multiple client types (web, mobile, etc.)
- â Need for flexible queries
- â Avoiding overfetching
- â Simple CRUD operations
- â Small teams (complexity overhead)
Migration Path
tRPC can coexist with REST/GraphQL:
// Use tRPC for internal, REST for public
const router = t.router({
internal: internalRouter, // tRPC only
});
// Expose REST endpoints separately
app.get('/api/public/users', publicRestHandler);
Migration from REST
Gradual Migration Strategy
- Add tRPC alongside REST: Don’t rewrite everything at once
- New features in tRPC: Start with new endpoints
- Migrate high-value endpoints: Focus on complex or frequently used APIs
- Keep public APIs in REST: Only migrate internal consumption
Converting REST to tRPC
Before (REST):
// pages/api/users/[id].ts
export default async function handler(req, res) {
if (req.method === 'GET') {
const user = await db.user.findUnique({ where: { id: req.query.id } });
res.json(user);
} else if (req.method === 'PATCH') {
const user = await db.user.update({
where: { id: req.query.id },
data: req.body,
});
res.json(user);
}
}
// Client
const response = await fetch(`/api/users/${id}`);
const user = await response.json(); // No types!
After (tRPC):
// server/routers/user.ts
export const userRouter = t.router({
getById: t.procedure
.input(z.string())
.query(({ input }) => db.user.findUnique({ where: { id: input } })),
update: t.procedure
.input(z.object({
id: z.string(),
data: z.object({ name: z.string().optional() }),
}))
.mutation(({ input }) => db.user.update({
where: { id: input.id },
data: input.data,
})),
});
// Client
const user = await trpc.user.getById.query(id); // Fully typed!
Shared Validation
// Reuse Zod schemas across REST and tRPC during migration
import { createUserSchema } from '../schemas/user';
// tRPC
const router = t.router({
createUser: t.procedure
.input(createUserSchema)
.mutation(({ input }) => createUser(input)),
});
// REST (validate with same schema)
export default async function handler(req, res) {
const parsed = createUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ errors: parsed.error });
}
const user = await createUser(parsed.data);
res.json(user);
}
Best Practices & Performance
Code Organization
server/
âââ trpc.ts # tRPC instance, base procedures
âââ context.ts # Context creation
âââ middleware/
â âââ auth.ts # Authentication middleware
â âââ logging.ts # Logging middleware
â âââ rateLimit.ts # Rate limiting
âââ routers/
â âââ _app.ts # Root router
â âââ user.ts # User procedures
â âââ post.ts # Post procedures
â âââ admin/
â âââ index.ts # Admin-only procedures
âââ schemas/
âââ user.ts # User Zod schemas
âââ post.ts # Post Zod schemas
Performance Tips
-
Use batching for multiple queries:
httpBatchLink({ url: '/api/trpc', maxBatchSize: 10 }) -
Implement DataLoader for N+1 queries:
const userLoader = new DataLoader(batchLoadUsers); -
Cache expensive queries:
trpc.posts.list.useQuery(undefined, { staleTime: 5 * 60 * 1000 }); -
Optimize database queries:
// â Bad: N+1 query const posts = await db.post.findMany(); const postsWithAuthors = await Promise.all( posts.map((p) => db.user.findUnique({ where: { id: p.authorId } })) ); // â Good: Single query with include const posts = await db.post.findMany({ include: { author: true }, }); -
Use React Query’s deduplication:
// Multiple components can call same queryâReact Query deduplicates const { data } = trpc.user.getMe.useQuery();
Security Best Practices
- Always validate input with Zod
- Use middleware for authentication:
const protectedProcedure = t.procedure.use(isAuthed); - Sanitize error messages in production
- Implement rate limiting
- Use HTTPS in production
- Set CORS properly:
createNextApiHandler({ router: appRouter, createContext, onError: ({ error }) => { if (error.code === 'INTERNAL_SERVER_ERROR') { console.error('Internal error:', error); } }, });
Type Safety Tips
-
Export router type, not implementation:
export type AppRouter = typeof appRouter; // â // Don't export `appRouter` itself to client -
Use
satisfiesfor better inference:const input = { name: 'Alice', age: 30, } satisfies CreateUserInput; -
Avoid
anyin context:// â Bad ctx: { user: any } // â Good ctx: { user: User | null }
Development Workflow
- Define schema first: Write Zod schemas before procedures
- Test procedures in isolation: Use
createCallerfor unit tests - Use TypeScript strict mode: Catch type errors early
- Enable React Query DevTools:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; <ReactQueryDevtools initialIsOpen={false} />
Common Pitfalls
â Don’t return sensitive data:
// Bad: Exposes password hash
.query(() => db.user.findMany())
// Good: Select specific fields
.query(() => db.user.findMany({ select: { id: true, name: true } }))
â Don’t use mutations for reads:
// Bad: Side-effect-free operation as mutation
getMostRecentPost: t.procedure.mutation(() => getPost())
// Good: Use query for reads
getMostRecentPost: t.procedure.query(() => getPost())
â Don’t skip input validation:
// Bad: No validation
.input(z.any())
// Good: Strict validation
.input(z.object({ id: z.string().uuid() }))
Monitoring & Observability
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
// Log metrics
metrics.increment('trpc.error', { code: error.code });
// Send to error tracking
if (error.code === 'INTERNAL_SERVER_ERROR') {
Sentry.captureException(error);
}
return shape;
},
});
const loggingMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
// Log performance metrics
metrics.timing('trpc.duration', Date.now() - start, { path, type });
return result;
});
Summary
tRPC enables type-safe APIs with minimal boilerplate:
- â No code generation: Types inferred from TypeScript
- â React Query integration: Built-in caching and optimistic updates
- â Next.js first-class support: App Router, Server Components
- â Developer experience: Auto-complete, refactoring, type errors
Best for: Full-stack TypeScript apps, Next.js projects, internal tools Avoid for: Public APIs, multi-language services
Get Started: Install â Define router â Use in client â Enjoy type safety!
Related Skills: Zod (validation), React Query (caching), Next.js (integration)