tRPC Core Knowledge
Deep Knowledge: Use mcp__documentation__fetch_docs with technology: trpc for comprehensive documentation.
Router Definition
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.context<Context>().create();
export const appRouter = t.router({
user: t.router({
list: t.procedure.query(async ({ ctx }) => {
return ctx.db.users.findMany();
}),
byId: t.procedure
.input(z.string())
.query(async ({ ctx, input }) => {
const user = await ctx.db.users.find(input);
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
return user;
}),
create: t.procedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
}))
.mutation(async ({ ctx, input }) => {
return ctx.db.users.create(input);
}),
}),
});
export type AppRouter = typeof appRouter;
Client Usage (React)
import { trpc } from '../utils/trpc';
function UserList() {
const { data, isLoading } = trpc.user.list.useQuery();
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
utils.user.list.invalidate();
},
});
if (isLoading) return <Spinner />;
return (
<div>
{data?.map(user => <UserCard key={user.id} user={user} />)}
<button => createUser.mutate({ name: 'John', email: 'j@example.com' })}>
Add User
</button>
</div>
);
}
Protected Procedures
const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { user: ctx.session.user } });
});
export const appRouter = t.router({
secret: protectedProcedure.query(({ ctx }) => {
return `Hello ${ctx.user.name}`;
}),
});
With Next.js
// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';
export default createNextApiHandler({
router: appRouter,
createContext: ({ req, res }) => ({ req, res, db }),
});
When NOT to Use This Skill
- REST API design (use
rest-api skill)
- GraphQL APIs (use
graphql skill)
- OpenAPI documentation (use
openapi skill)
- Non-TypeScript projects
- Public APIs requiring language-agnostic clients
- APIs consumed by third-party developers
Anti-Patterns
| Anti-Pattern |
Why It's Bad |
Solution |
| No input validation |
Security risk, runtime errors |
Always use Zod schemas for input |
| Sharing database models as output types |
Leaks implementation details |
Create separate DTOs/response schemas |
| Missing error handling middleware |
Inconsistent error responses |
Add global error middleware |
| No rate limiting on public procedures |
API abuse vulnerability |
Add rate limiting middleware |
Using any in context or procedures |
Loses type safety |
Use proper TypeScript types |
| Not using middleware for auth |
Duplicated auth logic |
Create reusable protected procedure |
| Missing pagination on list queries |
Performance issues |
Add pagination to all list endpoints |
| Exposing internal errors to client |
Security leak |
Use error formatter to sanitize errors |
| No request logging |
Hard to debug issues |
Add logging middleware |
Quick Troubleshooting
| Issue |
Possible Cause |
Solution |
| Type errors in client |
Router type not exported/imported |
Export AppRouter type from server |
| "UNAUTHORIZED" errors |
Missing or invalid context |
Check createContext, verify token |
| Input validation fails |
Input doesn't match Zod schema |
Verify request payload matches schema |
| Slow queries |
Missing DataLoader or N+1 queries |
Add batching/caching in context |
| CORS errors |
Missing CORS configuration |
Configure CORS in adapter |
| Procedure not found |
Router not registered or typo |
Check router structure, verify path |
| "Cannot call procedure" |
Calling query as mutation or vice versa |
Use correct method (query/mutation) |
| Rate limit errors |
Too many requests |
Implement exponential backoff |
| Type inference not working |
Incorrect client setup |
Ensure client uses correct AppRouter type |
Production Readiness
Error Handling
import { initTRPC, TRPCError } from '@trpc/server';
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
// Add custom error data
zodError:
error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
// Custom error handling middleware
const errorMiddleware = t.middleware(async ({ next }) => {
try {
return await next();
} catch (error) {
if (error instanceof TRPCError) {
// Log known errors
console.warn('tRPC error:', error.code, error.message);
throw error;
}
// Log and transform unknown errors
console.error('Unexpected error:', error);
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred',
});
}
});
export const procedure = t.procedure.use(errorMiddleware);
Rate Limiting
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, '1 m'),
});
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
const identifier = ctx.session?.user?.id ?? ctx.ip ?? 'anonymous';
const { success, limit, reset, remaining } = await ratelimit.limit(identifier);
if (!success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: `Rate limit exceeded. Try again in ${Math.ceil((reset - Date.now()) / 1000)}s`,
});
}
return next();
});
export const rateLimitedProcedure = t.procedure.use(rateLimitMiddleware);
Input Validation
import { z } from 'zod';
// Reusable schemas
const paginationSchema = z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
});
const idSchema = z.string().uuid();
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(['user', 'admin']).default('user'),
});
export const appRouter = t.router({
user: t.router({
list: procedure
.input(paginationSchema)
.query(async ({ ctx, input }) => {
const { page, limit } = input;
return ctx.db.users.findMany({
skip: (page - 1) * limit,
take: limit,
});
}),
create: protectedProcedure
.input(createUserSchema)
.mutation(async ({ ctx, input }) => {
return ctx.db.users.create({ data: input });
}),
}),
});
Testing
// server/routers/__tests__/user.test.ts
import { createCallerFactory } from '@trpc/server';
import { appRouter } from '../_app';
import { createMockContext } from '../../test/context';
const createCaller = createCallerFactory(appRouter);
describe('user router', () => {
it('lists users', async () => {
const ctx = createMockContext({
db: {
users: {
findMany: vi.fn().mockResolvedValue([{ id: '1', name: 'Test' }]),
},
},
});
const caller = createCaller(ctx);
const result = await caller.user.list({ page: 1, limit: 10 });
expect(result).toHaveLength(1);
expect(ctx.db.users.findMany).toHaveBeenCalled();
});
it('throws on unauthorized access', async () => {
const ctx = createMockContext({ session: null });
const caller = createCaller(ctx);
await expect(caller.user.create({ name: 'Test', email: 'test@example.com' }))
.rejects.toThrow('UNAUTHORIZED');
});
});
// E2E testing
import { test, expect } from '@playwright/test';
test('creates user via tRPC', async ({ page }) => {
await page.goto('/users');
await page.fill('input[name="name"]', 'John');
await page.fill('input[name="email"]', 'john@example.com');
await page.click('button[type="submit"]');
await expect(page.locator('text=John')).toBeVisible();
});
Monitoring Metrics
| Metric |
Target |
| Procedure latency |
< 200ms |
| Error rate |
< 1% |
| Rate limit hits |
Monitor |
| Query cache hit rate |
> 80% |
Checklist
Reference Documentation
- Middleware
- Error Handling