- Published on
tRPC in Production — End-to-End Type Safety Without the GraphQL Overhead
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
tRPC eliminates the traditional separation between client and server type definitions. Instead of defining types twice—once in your API schema and again in your client—tRPC uses TypeScript inference to ensure your entire stack shares one source of truth. This post covers production patterns for tRPC including routers, validation, middleware, infinite queries, WebSocket subscriptions, and the key limitation: tRPC doesn't work for public APIs or non-TypeScript clients.
- Router Architecture and Procedure Types
- Input Validation with Zod
- Context and Middleware
- Infinite Queries and Pagination
- WebSocket Subscriptions
- Error Handling and Custom Error Types
- When tRPC Doesn't Fit
- tRPC Limitations Checklist
- Conclusion
Router Architecture and Procedure Types
tRPC supports three procedure types: queries (read-only), mutations (write), and subscriptions (real-time). Your router becomes a single source of truth for your API contract.
import { z } from 'zod';
import { initTRPC, TRPCError } from '@trpc/server';
import { createHTTPServer } from '@trpc/server/adapters/standalone';
const t = initTRPC
.context<{ userId?: string }>()
.create({
errorFormatter: ({ shape, error }) => ({
...shape,
data: {
...shape.data,
zodError: error.cause instanceof z.ZodError ? error.cause.flatten() : null,
},
}),
});
const router = t.router({
users: t.router({
getById: t.procedure
.input(z.string().uuid('Invalid user ID'))
.query(async ({ input, ctx }) => {
if (!ctx.userId) throw new TRPCError({ code: 'UNAUTHORIZED' });
return db.user.findUnique({ where: { id: input } });
}),
create: t.procedure
.input(
z.object({
email: z.string().email(),
name: z.string().min(1),
role: z.enum(['admin', 'user', 'viewer']).default('user'),
})
)
.mutation(async ({ input }) => {
return db.user.create({ data: input });
}),
list: t.procedure
.input(
z.object({
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(),
})
)
.query(async ({ input }) => {
const items = await db.user.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
return {
items: items.slice(0, input.limit),
nextCursor: items.length > input.limit ? items[input.limit].id : null,
};
}),
}),
});
export type AppRouter = typeof router;
export default router;
Input Validation with Zod
Zod schemas are parsed on every request. Use .safeParse() to handle validation errors gracefully or let .parse() throw for error boundaries.
const createPostSchema = z.object({
title: z.string().min(5).max(200),
content: z.string().min(10).max(50000),
tags: z.array(z.string().min(1).max(50)).min(1).max(10),
published: z.boolean().default(false),
scheduledFor: z.date().optional(),
metadata: z
.object({
estimatedReadTime: z.number().positive().optional(),
seoKeywords: z.array(z.string()).optional(),
})
.optional(),
});
const postsRouter = t.router({
create: t.procedure
.input(createPostSchema)
.mutation(async ({ input }) => {
// Input is type-safe and validated
const post = await db.post.create({
data: {
...input,
slug: slugify(input.title),
},
});
return post;
}),
bulk: t.procedure
.input(z.array(createPostSchema).max(100))
.mutation(async ({ input }) => {
return db.post.createMany({
data: input.map(p => ({ ...p, slug: slugify(p.title) })),
});
}),
});
Context and Middleware
Context flows through all procedures. Middleware runs before input parsing or can transform the router.
const createContext = async ({ req }: { req?: Request }) => {
const token = req?.headers.get('authorization')?.replace('Bearer ', '');
let userId: string | undefined;
if (token) {
try {
const decoded = await verifyJWT(token);
userId = decoded.sub;
} catch (e) {
// Token invalid, userId stays undefined
}
}
return { userId, req, db };
};
type Context = Awaited<ReturnType<typeof createContext>>;
const t = initTRPC.context<Context>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
...ctx,
userId: ctx.userId, // Narrowed to non-optional
},
});
});
const authedProcedure = t.procedure.use(isAuthed);
const router = t.router({
me: authedProcedure
.query(({ ctx }) => {
// ctx.userId is guaranteed here
return db.user.findUnique({ where: { id: ctx.userId } });
}),
admin: t.router({
stats: authedProcedure
.use(
t.middleware(({ ctx, next }) => {
if (ctx.user?.role !== 'admin') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Admin only',
});
}
return next();
})
)
.query(async () => {
return db.analytics.getSystemStats();
}),
}),
});
Infinite Queries and Pagination
Use useInfiniteQuery on the client for cursor-based pagination with automatic scroll-loading.
const router = t.router({
feed: t.procedure
.input(
z.object({
limit: z.number().min(1).max(100).default(25),
cursor: z.string().nullish(),
filter: z.enum(['all', 'following', 'liked']).default('all'),
})
)
.query(async ({ input, ctx }) => {
const whereCondition =
input.filter === 'following'
? { authorId: { in: ctx.user.following } }
: input.filter === 'liked'
? { id: { in: ctx.user.likedPostIds } }
: {};
const posts = await db.post.findMany({
where: whereCondition,
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
include: { author: true, _count: { select: { likes: true } } },
});
return {
posts: posts.slice(0, input.limit),
nextCursor: posts.length > input.limit ? posts[input.limit].id : null,
};
}),
});
// Client usage:
const { data, fetchNextPage, hasNextPage } = trpc.feed.useInfiniteQuery(
{ filter: 'following' },
{ getNextPageParam: lastPage => lastPage.nextCursor }
);
WebSocket Subscriptions
Subscriptions enable real-time updates via WebSocket. Use them for notifications, live feeds, or collaborative features.
const router = t.router({
notifications: t.procedure
.input(z.object({ userId: z.string().uuid() }))
.subscription(({ input }) => {
return observable(emit => {
const handler = async (notification: Notification) => {
if (notification.recipientId === input.userId) {
emit.next(notification);
}
};
eventBus.on('notification:created', handler);
return () => eventBus.off('notification:created', handler);
});
}),
postLiveCount: t.procedure
.input(z.string().uuid())
.subscription(({ input: postId }) => {
return observable(emit => {
const initialCount = db.post.findUnique({
where: { id: postId },
select: { _count: { select: { likes: true } } },
});
emit.next(initialCount);
const handler = () => {
db.post.findUnique({
where: { id: postId },
select: { _count: { select: { likes: true } } },
});
emit.next(initialCount);
};
eventBus.on(`post:${postId}:liked`, handler);
return () => eventBus.off(`post:${postId}:liked`, handler);
});
}),
});
Error Handling and Custom Error Types
tRPC error formatting provides structured error responses. Define domain-specific errors.
const TRPCError = {
VALIDATION_FAILED: 'BAD_REQUEST',
USER_NOT_FOUND: 'NOT_FOUND',
EMAIL_TAKEN: 'CONFLICT',
RATE_LIMITED: 'TOO_MANY_REQUESTS',
INSUFFICIENT_PERMISSION: 'FORBIDDEN',
} as const;
const router = t.router({
users: t.router({
signUp: t.procedure
.input(signUpSchema)
.mutation(async ({ input }) => {
const existing = await db.user.findUnique({
where: { email: input.email },
});
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Email already registered',
cause: { field: 'email' },
});
}
return db.user.create({ data: input });
}),
sendInvite: t.procedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input, ctx }) => {
const rateLimit = await redis.incr(
`invite:${ctx.userId}:${Date.now() / 3600000}`
);
if (rateLimit > 10) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Max 10 invites per hour',
});
}
return sendInviteEmail(input.email);
}),
}),
});
When tRPC Doesn't Fit
tRPC requires TypeScript on both client and server. Public APIs, native mobile apps, and third-party integrations need REST or GraphQL instead.
tRPC Limitations Checklist
- All consumers are TypeScript applications
- You control both client and server code
- API is internal or partners are willing to use TypeScript clients
- You don't need API documentation for non-technical stakeholders
- Monorepo structure is feasible for sharing types
- No need for GraphQL federation or complex query languages
- Don't require automatic OpenAPI documentation
Conclusion
tRPC delivers exceptional developer experience for full-stack TypeScript applications through automatic type inference and elimination of boilerplate. Start with simple routers, layer on middleware for cross-cutting concerns, and use subscriptions for real-time features. Recognize its constraint—TypeScript clients only—and choose REST or GraphQL for public APIs.