toolchains/typescript/api/trpc/SKILL.md
tRPC end-to-end type-safe APIs for TypeScript with React Query integration and full-stack type safety
npx skillsauth add bobmatnyc/claude-mpm-skills trpc-type-safetyInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
✅ Perfect For:
❌ Avoid When:
When to Choose:
# Server dependencies
npm install @trpc/server zod
# React/Next.js client dependencies
npm install @trpc/client @trpc/react-query @tanstack/react-query
// 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;
// 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.
tRPC provides type-safe remote procedure calls by sharing TypeScript types between client and server. No code generation—just TypeScript's inference.
// 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 }
┌─────────────┐ Type-safe ┌──────────────┐
│ Client │ ←────────────────→ │ Server │
│ (React) │ No codegen! │ (Node.js) │
└─────────────┘ └──────────────┘
↓ ↓
React Query tRPC Router
(caching) (procedures)
Advantages:
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const appRouter = t.router({
// Procedures go here
});
export type AppRouter = typeof appRouter;
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()
import { adminRouter } from './admin';
import { publicRouter } from './public';
export const appRouter = t.mergeRouters(publicRouter, adminRouter);
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
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,
});
}),
});
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,
});
}),
});
| 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. |
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.
tRPC uses Zod for runtime type validation and TypeScript inference. Zod schemas provide:
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);
}),
});
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);
}),
});
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 } });
}),
});
// 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 provides request-scoped data to all procedures—authentication, database connections, logging, etc.
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();
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,
},
});
}),
});
// ✅ 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 intercepts procedure calls to add cross-cutting concerns: logging, timing, authentication, rate limiting.
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);
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);
}),
});
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);
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
},
});
});
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;
}),
});
| 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 |
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,
});
}
}),
});
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,
},
};
},
});
function MyComponent() {
const mutation = trpc.createUser.useMutation({
onError: (error) => {
if (error.data?.code === 'UNAUTHORIZED') {
router.push('/login');
} else {
toast.error(error.message);
}
},
});
}
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' });
// 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>
);
}
// 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,
});
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
headers: async () => {
const token = await getAuthToken();
return {
authorization: token ? `Bearer ${token}` : undefined,
};
},
}),
],
});
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>;
}
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'),
});
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>
);
}
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();
},
});
// 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>
);
}
// 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>
);
}
// 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/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>
);
}
// 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>
);
}
// 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 };
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');
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;
}),
});
import { createWSClient, wsLink } from '@trpc/client';
const wsClient = createWSClient({
url: 'ws://localhost:3001',
});
const trpcClient = trpc.createClient({
links: [
wsLink({
client: wsClient,
}),
],
});
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>
);
}
// 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),
});
// 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 });
});
}
// 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);
};
// 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 });
}
// 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
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;
}),
});
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' }),
}),
],
});
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>;
}
import type { inferProcedureInput, inferProcedureOutput } from '@trpc/server';
type CreatePostInput = inferProcedureInput<AppRouter['post']['create']>;
type Post = inferProcedureOutput<AppRouter['post']['getById']>;
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();
// 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 })
),
});
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: '[email protected]',
});
expect(result).toMatchObject({
name: 'Alice',
email: '[email protected]',
});
});
});
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');
});
});
import { createCaller } from '../routers';
const mockContext = {
db: {
user: {
findUnique: vi.fn().mockResolvedValue({ id: '1', name: 'Alice' }),
create: vi.fn(),
},
},
session: {
user: { id: '1', email: '[email protected]' },
},
};
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');
});
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>
);
}
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,
},
};
},
});
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();
});
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;
}),
});
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;
}
});
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();
}
});
});
| 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 |
tRPC:
REST:
GraphQL:
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);
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!
// 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);
}
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
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();
const protectedProcedure = t.procedure.use(isAuthed);
createNextApiHandler({
router: appRouter,
createContext,
onError: ({ error }) => {
if (error.code === 'INTERNAL_SERVER_ERROR') {
console.error('Internal error:', error);
}
},
});
Export router type, not implementation:
export type AppRouter = typeof appRouter; // ✅
// Don't export `appRouter` itself to client
Use satisfies for better inference:
const input = {
name: 'Alice',
age: 30,
} satisfies CreateUserInput;
Avoid any in context:
// ❌ Bad
ctx: { user: any }
// ✅ Good
ctx: { user: User | null }
createCaller for unit testsimport { ReactQueryDevtools } from '@tanstack/react-query-devtools';
<ReactQueryDevtools initialIsOpen={false} />
❌ 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() }))
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;
});
tRPC enables type-safe APIs with minimal boilerplate:
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)
development
Axum (Rust) web framework patterns for production APIs: routers/extractors, state, middleware, error handling, tracing, graceful shutdown, and testing
development
Optimize web performance using Core Web Vitals, modern patterns (View Transitions, Speculation Rules), and framework-specific techniques
development
Best practices for documenting APIs and code interfaces, eliminating redundant documentation guidance per agent.
development
Comprehensive API design patterns covering REST, GraphQL, gRPC, versioning, authentication, and modern API best practices