Introduction
tRPC v10, released in late 2022, represented a significant evolution of the library that had already revolutionized TypeScript API development. While tRPC v9 proved the concept of zero-overhead type safety, v10 refined the developer experience with a simplified builder pattern, improved middleware composition, enhanced subscription support, and deeper React Query integration. The release wasn't just incremental—it was a thoughtful reimagining of how type-safe APIs should feel to write.
This guide covers everything you need to know about tRPC v10: the new syntax, migration strategies from v9, advanced patterns with middleware and subscriptions, and production-ready integration with React Query v4. Whether you're upgrading an existing project or starting fresh, this comprehensive walkthrough will help you leverage v10's full potential.
Understanding tRPC v10: What Changed
The New Builder Pattern
The most immediately visible change in v10 is the new builder pattern. Instead of the functional approach of v9, v10 uses an instance-based approach that feels more natural and enables better TypeScript inference.
// tRPC v9 (old way)
import { createRouter } from '@trpc/server';
export const appRouter = createRouter()
.query('hello', {
input: z.object({ name: z.string() }),
resolve({ input }) {
return `Hello ${input.name}`;
},
});
// tRPC v10 (new way)
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const appRouter = t.router({
hello: t.procedure
.input(z.object({ name: z.string() }))
.query(({ input }) => {
return `Hello ${input.name}`;
}),
});Key Improvements in v10
| Feature | v9 | v10 |
|---|---|---|
| Router definition | Chained .query() / .mutation() | Object-based t.router({}) |
| Procedure creation | String keys | t.procedure builder |
| Middleware | Manual composition | .use() chain |
| Subscriptions | Basic support | Full WebSocket support |
| Error handling | Custom error classes | Standardized error codes |
| Context typing | Manual generics | Automatic inference |
| React Query integration | Separate package | Built-in with v4 |
Why the Breaking Changes?
The v9 API had several pain points:
- String-based procedure names didn't provide good autocomplete
- Middleware composition was verbose and error-prone
- Context typing required manual generic parameters
- Subscription support was experimental
v10 addressed all of these while maintaining tRPC's core promise of zero runtime overhead.
Step-by-Step Implementation
Server Setup with v10
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import superjson from 'superjson';
interface Context {
userId: string | null;
req: Request;
db: Database;
}
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof z.ZodError
? error.cause.flatten()
: null,
},
};
},
});
// Export building blocks
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;Middleware Composition in v10
v10's middleware system is significantly more composable. You can chain middleware, access the previous context, and transform results:
// Logging middleware
const loggerMiddleware = middleware(async ({ path, type, next, ctx }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
console.log(`${type} ${path} - ${duration}ms`);
return result;
});
// Authentication middleware
const authMiddleware = middleware(async ({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in',
});
}
return next({
ctx: {
...ctx,
userId: ctx.userId, // Non-nullable after auth
},
});
});
// Rate limiting middleware
const rateLimitMiddleware = middleware(async ({ ctx, next, path }) => {
const key = `rate:${ctx.userId ?? ctx.req.headers.get('x-forwarded-for')}:${path}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, 60);
}
if (current > 100) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded. Try again in 60 seconds.',
});
}
return next();
});
// Compose middleware into procedure builders
export const protectedProcedure = publicProcedure
.use(loggerMiddleware)
.use(authMiddleware);
export const rateLimitedProcedure = protectedProcedure
.use(rateLimitMiddleware);Router Definition Patterns
v10 encourages organizing routers by domain and composing them:
// routers/user.ts
import { z } from 'zod';
import { router, protectedProcedure } from '../trpc';
export const userRouter = router({
me: protectedProcedure.query(({ ctx }) => {
return ctx.db.user.findUnique({
where: { id: ctx.userId },
include: { profile: true, settings: true },
});
}),
update: protectedProcedure
.input(
z.object({
name: z.string().min(2).max(50).optional(),
bio: z.string().max(500).optional(),
avatar: z.string().url().optional(),
})
)
.mutation(async ({ input, ctx }) => {
return ctx.db.user.update({
where: { id: ctx.userId },
data: input,
});
}),
byId: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id },
select: { id: true, name: true, avatar: true, bio: true },
});
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return user;
}),
});Root Router Composition
// root.ts
import { router } from './trpc';
import { userRouter } from './routers/user';
import { postRouter } from './routers/post';
import { commentRouter } from './routers/comment';
import { notificationRouter } from './routers/notification';
export const appRouter = router({
user: userRouter,
post: postRouter,
comment: commentRouter,
notification: notificationRouter,
});
export type AppRouter = typeof appRouter;React Query Integration
v10 deepened the integration with React Query v4, providing a more seamless experience:
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/root';
export const trpc = createTRPCReact<AppRouter>();
// providers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { trpc } from './utils/trpc';
import { useState } from 'react';
import superjson from 'superjson';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: (failureCount, error) => {
if (error.data?.code === 'UNAUTHORIZED') return false;
return failureCount < 3;
},
},
},
})
);
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === 'development' ||
(opts.direction === 'down' && opts.result instanceof Error),
}),
httpBatchLink({
url: '/api/trpc',
transformer: superjson,
headers() {
const token = localStorage.getItem('token');
return token ? { authorization: `Bearer ${token}` } : {};
},
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}Using Hooks in Components
// components/PostFeed.tsx
import { trpc } from '../utils/trpc';
import { useInView } from 'react-intersection-observer';
export function PostFeed() {
const { ref, inView } = useInView();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = trpc.post.feed.useInfiniteQuery(
{ limit: 10 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
// Auto-fetch next page when sentinel is visible
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
const likePost = trpc.post.like.useMutation({
onMutate: async ({ postId }) => {
await utils.post.feed.cancel();
const previous = utils.post.feed.getInfiniteData({ limit: 10 });
utils.post.feed.setInfiniteData({ limit: 10 }, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
posts: page.posts.map((post) =>
post.id === postId
? { ...post, likes: post.likes + 1, isLiked: true }
: post
),
})),
};
});
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
utils.post.feed.setInfiniteData({ limit: 10 }, context.previous);
}
},
});
if (isLoading) return <PostFeedSkeleton />;
return (
<div>
{data?.pages.flatMap((page) =>
page.posts.map((post) => (
<PostCard
key={post.id}
post={post}
onLike={() => likePost.mutate({ postId: post.id })}
/>
))
)}
<div ref={ref} />
{isFetchingNextPage && <LoadingSpinner />}
</div>
);
}Real-World Use Cases
Use Case 1: E-Commerce Platform
tRPC v10's middleware system shines in e-commerce where you need role-based access:
const roleMiddleware = (allowedRoles: string[]) =>
middleware(async ({ ctx, next }) => {
if (!ctx.user || !allowedRoles.includes(ctx.user.role)) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
const adminProcedure = protectedProcedure.use(roleMiddleware(['ADMIN']));
const sellerProcedure = protectedProcedure.use(roleMiddleware(['SELLER', 'ADMIN']));
const productRouter = router({
create: sellerProcedure
.input(productSchema)
.mutation(async ({ input, ctx }) => {
return ctx.db.product.create({
data: { ...input, sellerId: ctx.user.id },
});
}),
approve: adminProcedure
.input(z.object({ productId: z.string() }))
.mutation(async ({ input, ctx }) => {
return ctx.db.product.update({
where: { id: input.productId },
data: { status: 'APPROVED', approvedBy: ctx.user.id },
});
}),
});Use Case 2: Real-Time Chat Application
v10's improved subscription support enables real-time features:
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';
const ee = new EventEmitter();
const chatRouter = router({
sendMessage: protectedProcedure
.input(z.object({
channelId: z.string(),
content: z.string().min(1).max(5000),
}))
.mutation(async ({ input, ctx }) => {
const message = await ctx.db.message.create({
data: {
channelId: input.channelId,
content: input.content,
authorId: ctx.userId,
},
include: { author: true },
});
ee.emit('message', message);
return message;
}),
onMessage: protectedProcedure
.input(z.object({ channelId: z.string() }))
.subscription(({ input }) => {
return observable<Message>((emit) => {
const onMessage = (message: Message) => {
if (message.channelId === input.channelId) {
emit.next(message);
}
};
ee.on('message', onMessage);
return () => {
ee.off('message', onMessage);
};
});
}),
});Best Practices for Migration from v9
- Use the codemod: tRPC provides an official codemod for automated migration
- Migrate router by router: Don't try to convert everything at once
- Update client code incrementally: The client API changes are simpler
- Test thoroughly after migration: Type errors are caught at compile time, but runtime behavior should be verified
- Update React Query to v4: tRPC v10 requires React Query v4
# Official codemod for automated migration
npx @trpc/v10-migrateCommon Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Mixing v9 and v10 syntax | Confusing errors | Complete migration per router |
Missing superjson transformer | Date/Map objects serialized incorrectly | Add transformer to both client and server |
| Circular router imports | Build errors | Use barrel exports and lazy loading |
| Over-nesting middleware | Performance overhead | Keep middleware focused and minimal |
| Not handling subscription cleanup | Memory leaks | Always return cleanup function in subscriptions |
Performance Optimization
Selective Batching
// Disable batching for large responses
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
maxURLLength: 2048, // Prevent URL too long errors
}),
],
});Query Optimization
// Configure per-query options
const { data } = trpc.post.byId.useQuery(
{ id: postId },
{
staleTime: 60 * 1000, // 1 minute for individual posts
cacheTime: 5 * 60 * 1000, // 5 minutes in cache
refetchOnWindowFocus: false,
}
);Comparison with Alternatives
| Feature | tRPC v10 | GraphQL | REST + OpenAPI |
|---|---|---|---|
| Type safety | Automatic | Code gen | Code gen |
| Learning curve | Low | High | Medium |
| Boilerplate | Minimal | High | Medium |
| Bundle size | ~15KB | 30KB+ | 0 |
| Real-time | Built-in | Built-in | Custom |
| Caching | React Query | Apollo | Custom |
| File uploads | Supported | Multipart | Multipart |
Advanced Patterns
Procedure-Level Authorization
// Dynamic authorization based on resource ownership
const withOwnership = (getResourceOwnerId: (input: any, ctx: Context) => Promise<string>) =>
middleware(async ({ input, ctx, next }) => {
const ownerId = await getResourceOwnerId(input, ctx);
if (ownerId !== ctx.userId && ctx.user.role !== 'ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not own this resource',
});
}
return next();
});
const postRouter = router({
update: protectedProcedure
.use(
withOwnership(async (input, ctx) => {
const post = await ctx.db.post.findUnique({
where: { id: input.id },
select: { authorId: true },
});
return post?.authorId ?? '';
})
)
.input(z.object({ id: z.string(), title: z.string() }))
.mutation(async ({ input, ctx }) => {
return ctx.db.post.update({
where: { id: input.id },
data: { title: input.title },
});
}),
});Output Validation
// Validate outputs with Zod schemas
const withOutputValidation = <T extends z.ZodType>(schema: T) =>
middleware(async ({ next }) => {
const result = await next();
const parsed = schema.parse(result.data);
return { ...result, data: parsed };
});tRPC v10 with Next.js App Router
tRPC v10 integrates with the Next.js App Router using the @trpc/next adapter. The server-side handler processes tRPC requests through API route handlers, while the client-side hooks provide type-safe data fetching in React Server Components and client components:
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/root';
import { createInnerTRPCContext } from '@/server/trpc';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () =>
createInnerTRPCContext({
session: await getServerSession(),
headers: req.headers,
}),
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) => {
console.error(`❌ tRPC failed on ${path}: ${error.message}`);
}
: undefined,
});
export { handler as GET, handler as POST };For React Server Components, use createCaller to call procedures directly on the server without HTTP overhead:
// app/posts/[id]/page.tsx
import { createInnerTRPCContext } from '@/server/trpc';
import { appRouter } from '@/server/root';
export default async function PostPage({ params }: { params: { id: string } }) {
const ctx = await createInnerTRPCContext({ session: await getServerSession() });
const caller = appRouter.createCaller(ctx);
const post = await caller.post.byId({ id: params.id });
return <PostDetail post={post} />;
}This pattern eliminates the waterfall problem of client-side fetching in Server Components — the data is fetched during rendering on the server with full type safety, and the response is streamed to the client as HTML.
Testing Strategies
import { appRouter } from './root';
import { createInnerTRPCContext } from './trpc';
describe('tRPC v10 Router', () => {
const createTestContext = (userId?: string) =>
createInnerTRPCContext({
userId: userId ?? null,
db: mockDb,
req: new Request('http://localhost'),
});
test('procedure with middleware chain', async () => {
const ctx = createTestContext('user-1');
const caller = appRouter.createCaller(ctx);
const result = await caller.user.me();
expect(result.id).toBe('user-1');
});
test('middleware rejects unauthorized access', async () => {
const ctx = createTestContext(); // No user
const caller = appRouter.createCaller(ctx);
await expect(caller.user.me()).rejects.toThrow('UNAUTHORIZED');
});
test('input validation works correctly', async () => {
const ctx = createTestContext('user-1');
const caller = appRouter.createCaller(ctx);
await expect(
caller.user.update({ name: '' }) // Too short
).rejects.toThrow();
});
});Future Outlook
tRPC v10 laid the groundwork for future innovations:
- v11 roadmap: Improved error handling, better edge runtime support, enhanced subscription patterns
- Framework expansion: Deeper integrations with SvelteKit, SolidStart, and Fresh
- Developer tools: Better debugging, request tracing, and API documentation generation
- Performance improvements: Smaller bundle size, faster type inference
Production Deployment and Operations
Running backend services in production requires attention to reliability, observability, and operational concerns that don't exist in development environments. Proper deployment practices ensure your service remains available and performant under real-world conditions.
Graceful Shutdown Handling
Implement graceful shutdown to prevent request failures during deployments and restarts:
const server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
async function gracefulShutdown(signal) {
console.log(`Received ${signal}, starting graceful shutdown...`);
// Stop accepting new connections
server.close(async () => {
console.log('HTTP server closed');
try {
// Wait for existing requests to complete (with timeout)
await Promise.race([
waitForActiveRequests(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Shutdown timeout')), 30000)
),
]);
// Close database connections
await db.destroy();
await redis.quit();
console.log('Graceful shutdown completed');
process.exit(0);
} catch (error) {
console.error('Error during shutdown:', error);
process.exit(1);
}
});
// Force shutdown after timeout
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 35000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));Structured Logging
Replace console.log with structured logging that supports log aggregation and querying:
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level(label) {
return { level: label };
},
},
serializers: {
err: pino.stdSerializers.err,
req: pino.stdSerializers.req,
res: pino.stdSerializers.res,
},
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie'],
remove: true,
},
});
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
logger.info({
req,
res,
responseTime: Date.now() - start,
}, `${req.method} ${req.url} ${res.statusCode}`);
});
next();
});Rate Limiting and Abuse Prevention
Protect your API endpoints with rate limiting that adapts to different client types:
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const apiLimiter = rateLimit({
store: new RedisStore({ client: redisClient }),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.user?.id || req.ip,
handler: (req, res) => {
logger.warn({ ip: req.ip, user: req.user?.id }, 'Rate limit exceeded');
res.status(429).json({
error: 'Too many requests',
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
});
},
});
app.use('/api/', apiLimiter);These operational practices form the foundation of a reliable production service that can handle real-world traffic patterns and failure scenarios.
Community Resources and Further Learning
The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.
Curated Learning Pathways
Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.
Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.
Contributing to Open Source
Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.
# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
# Run the project's contribution setup
npm run setup:dev
npm run test # Ensure tests pass before making changes
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
Closes #1234"
git push origin fix/issue-descriptionBuilding a Technical Knowledge Base
Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.
Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.
Staying Current with Industry Trends
Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.
Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.
Mentorship and Knowledge Sharing
Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.
Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.
Conclusion
tRPC v10 represents a significant step forward in type-safe API development. The simplified builder pattern, composable middleware, and deep React Query integration make it the most productive way to build full-stack TypeScript applications.
Key takeaways:
- Simplified syntax: The builder pattern is more intuitive and provides better autocomplete
- Composable middleware: Chain middleware for auth, logging, rate limiting, and more
- Deep React Query integration: First-class hooks with optimistic updates and infinite queries
- Production-ready: Error handling, caching, and performance optimizations built-in
- Easy migration: Official codemods and incremental migration strategies
If you're starting a new TypeScript project or looking to improve your existing API layer, tRPC v10 offers the best developer experience in the ecosystem today.