A comprehensive exploration of tRPC, its origins, core concepts, and practical implementation with NestJS. Learn when tRPC provides superior developer experience over REST, GraphQL, and gRPC.

In the landscape of API protocols, tRPC represents a paradigm shift. While REST, GraphQL, and gRPC each solve specific problems, tRPC asks a different question: what if your frontend and backend share the same TypeScript codebase? What if you could eliminate API documentation, reduce boilerplate, and catch errors at compile time instead of runtime?
tRPC (TypeScript Remote Procedure Call) isn't just another API protocol—it's a philosophy that leverages TypeScript's type system to create end-to-end type safety between client and server. No code generation, no schema files, no manual type synchronization. Just pure TypeScript inference.
This deep dive explores tRPC's architecture, compares it against established protocols, and demonstrates a production-grade implementation using NestJS. If you've ever been frustrated by REST API documentation drift or GraphQL schema maintenance, tRPC offers a compelling alternative.
tRPC emerged in 2020 from the frustrations of full-stack TypeScript developers. Created by Alex "KATT" Johansson, it was born from a simple observation: when both client and server use TypeScript, why do we need separate schema definitions, code generators, or manual type synchronization?
Traditional API development involves several pain points:
REST APIs: You define endpoints, write OpenAPI specs (maybe), generate TypeScript types (hopefully), and pray they stay synchronized. When the backend changes, frontend breaks at runtime.
GraphQL: You write schema definitions, set up resolvers, generate TypeScript types from schemas, and maintain two sources of truth. Better than REST, but still manual work.
gRPC: You define Protocol Buffer schemas, generate code for multiple languages, and deal with complex tooling. Great for microservices, overkill for monorepos.
tRPC eliminates these problems by using TypeScript as the contract. Your backend procedures are your API contract. Types flow automatically from server to client through TypeScript's inference system.
tRPC introduced several concepts that changed full-stack TypeScript development:
Understanding when to use tRPC requires comparing it against established protocols.
REST dominates web APIs, but tRPC offers distinct advantages for TypeScript monorepos.
| Aspect | tRPC | REST |
|---|---|---|
| Type Safety | End-to-end automatic | Manual or codegen |
| Documentation | Types are docs | OpenAPI/Swagger |
| Versioning | TypeScript refactoring | URL versioning |
| Validation | Optional (Zod) | Manual |
| Learning Curve | Low (if you know TS) | Low |
| Monorepo Fit | Excellent | Poor |
| Public APIs | Not suitable | Excellent |
| Language Support | TypeScript only | Any language |
| Caching | Custom | HTTP caching |
Use tRPC when: You have a TypeScript monorepo, control both client and server, and want maximum type safety with minimal boilerplate.
Use REST when: You need public APIs, multi-language clients, or HTTP caching is critical.
GraphQL revolutionized data fetching, but tRPC takes a different approach to the same problems.
| Aspect | tRPC | GraphQL |
|---|---|---|
| Schema Definition | TypeScript types | SDL/Schema |
| Type Generation | Automatic inference | Codegen required |
| Query Flexibility | Fixed procedures | Client-defined queries |
| Overfetching | Possible | Eliminated |
| Complexity | Low | Moderate-High |
| Tooling | Minimal | Extensive |
| Real-time | WebSocket support | Subscriptions |
| N+1 Problem | Manual handling | DataLoader |
Use tRPC when: You want type safety without schema maintenance, and your API structure is procedure-based rather than graph-based.
Use GraphQL when: You need flexible querying, have multiple client types with different data needs, or want a mature ecosystem.
Despite similar names, tRPC and gRPC serve different purposes.
| Aspect | tRPC | gRPC |
|---|---|---|
| Protocol | HTTP/JSON | HTTP/2 + Protobuf |
| Type System | TypeScript | Protocol Buffers |
| Performance | Good (JSON) | Excellent (binary) |
| Browser Support | Native | Requires proxy |
| Language Support | TypeScript only | Multi-language |
| Streaming | Limited | Bidirectional |
| Setup Complexity | Minimal | Moderate |
| Use Case | Monorepo apps | Microservices |
Use tRPC when: You're building a TypeScript monorepo application and want seamless type safety.
Use gRPC when: You need high performance, multi-language support, or bidirectional streaming between microservices.
tRPC shines in specific scenarios where its constraints become advantages.
tRPC is ideal for applications where:
Real-world example: A SaaS dashboard where the same team builds React frontend and Node.js backend. Changes to user models automatically propagate to the UI.
Internal applications benefit from tRPC because:
Example: An internal CRM where sales, support, and operations teams use the same TypeScript application.
tRPC was designed with Next.js in mind:
Example: An e-commerce platform built with Next.js where product data, cart operations, and checkout flow all use tRPC.
Early-stage products benefit from tRPC's velocity:
Example: A new fintech startup building a mobile banking app with React Native and Node.js backend.
Understanding tRPC requires grasping its fundamental building blocks.
Procedures are the core of tRPC. They're type-safe functions that can be called from the client.
Query: Read-only operations (like GET in REST)
const getUser = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.user.findUnique({ where: { id: input.id } });
});Mutation: Operations that modify data (like POST/PUT/DELETE in REST)
const createUser = publicProcedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ input }) => {
return db.user.create({ data: input });
});Subscription: Real-time data streams
const onUserUpdate = publicProcedure
.input(z.object({ userId: z.string() }))
.subscription(async function* ({ input }) {
// Yield updates as they happen
for await (const update of userUpdateStream(input.userId)) {
yield update;
}
});Routers organize procedures into namespaces:
const userRouter = router({
getById: getUser,
create: createUser,
update: updateUser,
delete: deleteUser,
});
const appRouter = router({
user: userRouter,
post: postRouter,
comment: commentRouter,
});
export type AppRouter = typeof appRouter;The AppRouter type is the contract between client and server. No schema files, no code generation—just TypeScript types.
Context provides request-scoped data to procedures:
export const createContext = async ({ req, res }: CreateContextOptions) => {
const session = await getSession(req);
return {
session,
db: prisma,
req,
res,
};
};
export type Context = Awaited<ReturnType<typeof createContext>>;Every procedure receives this context, enabling authentication, database access, and request handling.
Middleware adds reusable logic to procedures:
const isAuthed = middleware(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
...ctx,
user: ctx.session.user, // Now guaranteed to exist
},
});
});
const protectedProcedure = publicProcedure.use(isAuthed);Middleware can modify context, validate permissions, log requests, or handle errors.
tRPC commonly uses Zod for runtime validation:
const createPostInput = z.object({
title: z.string().min(1).max(100),
content: z.string().min(10),
tags: z.array(z.string()).max(5),
published: z.boolean().default(false),
});
const createPost = protectedProcedure
.input(createPostInput)
.mutation(async ({ input, ctx }) => {
// input is fully typed and validated
return ctx.db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
});Zod provides both runtime validation and TypeScript types. If validation fails, tRPC returns a typed error.
tRPC has standardized error codes:
import { TRPCError } from '@trpc/server';
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid post ID',
cause: originalError,
});Error codes include:
BAD_REQUEST: Invalid inputUNAUTHORIZED: Authentication requiredFORBIDDEN: Insufficient permissionsNOT_FOUND: Resource doesn't existTIMEOUT: Operation took too longCONFLICT: Resource conflict (e.g., duplicate)INTERNAL_SERVER_ERROR: Unexpected server errorClients receive typed errors with proper HTTP status codes.
Let's build a production-grade tRPC API using NestJS. We'll create a blog platform with posts, comments, and user management.
First, install dependencies:
npm i -g @nestjs/cli
nest new trpc-blog-api
cd trpc-blog-apiCreate context that provides database access and authentication:
import { Injectable } from '@nestjs/common';
import { Request, Response } from 'express';
export interface Session {
userId: string;
email: string;
role: 'user' | 'admin';
}
export interface TRPCContext {
req: Request;
res: Response;
session: Session | null;
}
@Injectable()
export class TRPCContextService {
async create({ req, res }: { req: Request; res: Response }): Promise<TRPCContext> {
// Extract session from JWT token or session cookie
const session = await this.getSession(req);
return {
req,
res,
session,
};
}
private async getSession(req: Request): Promise<Session | null> {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return null;
}
try {
// Verify JWT and extract user info
// In production, use proper JWT verification
const decoded = JSON.parse(
Buffer.from(token.split('.')[1], 'base64').toString()
);
return {
userId: decoded.userId,
email: decoded.email,
role: decoded.role || 'user',
};
} catch {
return null;
}
}
}Create the tRPC instance with middleware:
import { Injectable } from '@nestjs/common';
import { initTRPC, TRPCError } from '@trpc/server';
import { TRPCContext } from './trpc.context';
import superjson from 'superjson';
@Injectable()
export class TRPCService {
private readonly trpc = initTRPC.context<TRPCContext>().create({
transformer: superjson, // Enables Date, Map, Set serialization
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof Error ? error.cause.message : null,
},
};
},
});
// Public procedure - no authentication required
public readonly publicProcedure = this.trpc.procedure;
// Protected procedure - requires authentication
public readonly protectedProcedure = this.trpc.procedure.use(
this.trpc.middleware(async ({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Authentication required',
});
}
return next({
ctx: {
...ctx,
session: ctx.session, // Now guaranteed to exist
},
});
})
);
// Admin procedure - requires admin role
public readonly adminProcedure = this.protectedProcedure.use(
this.trpc.middleware(async ({ ctx, next }) => {
if (ctx.session.role !== 'admin') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Admin access required',
});
}
return next({ ctx });
})
);
public readonly router = this.trpc.router;
public readonly mergeRouters = this.trpc.mergeRouters;
}Define TypeScript interfaces for our domain:
export interface Post {
id: string;
title: string;
content: string;
excerpt: string;
published: boolean;
authorId: string;
createdAt: Date;
updatedAt: Date;
tags: string[];
}
export interface Comment {
id: string;
content: string;
postId: string;
authorId: string;
createdAt: Date;
updatedAt: Date;
}
export interface User {
id: string;
email: string;
name: string;
role: 'user' | 'admin';
createdAt: Date;
}Create business logic for post operations:
import { Injectable, Logger } from '@nestjs/common';
import { Post } from '../models/post.model';
import { randomUUID } from 'crypto';
@Injectable()
export class PostsService {
private readonly logger = new Logger(PostsService.name);
// In-memory storage for demo (use Prisma/TypeORM in production)
private posts: Map<string, Post> = new Map();
async findAll(filters?: { published?: boolean; authorId?: string }): Promise<Post[]> {
let posts = Array.from(this.posts.values());
if (filters?.published !== undefined) {
posts = posts.filter(p => p.published === filters.published);
}
if (filters?.authorId) {
posts = posts.filter(p => p.authorId === filters.authorId);
}
return posts.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
async findById(id: string): Promise<Post | null> {
return this.posts.get(id) || null;
}
async create(data: {
title: string;
content: string;
excerpt: string;
tags: string[];
published: boolean;
authorId: string;
}): Promise<Post> {
const post: Post = {
id: randomUUID(),
...data,
createdAt: new Date(),
updatedAt: new Date(),
};
this.posts.set(post.id, post);
this.logger.log(`Post created: ${post.id}`);
return post;
}
async update(
id: string,
data: Partial<Omit<Post, 'id' | 'authorId' | 'createdAt'>>
): Promise<Post> {
const post = this.posts.get(id);
if (!post) {
throw new Error('Post not found');
}
const updated: Post = {
...post,
...data,
updatedAt: new Date(),
};
this.posts.set(id, updated);
this.logger.log(`Post updated: ${id}`);
return updated;
}
async delete(id: string): Promise<void> {
const deleted = this.posts.delete(id);
if (!deleted) {
throw new Error('Post not found');
}
this.logger.log(`Post deleted: ${id}`);
}
async countByAuthor(authorId: string): Promise<number> {
return Array.from(this.posts.values()).filter(p => p.authorId === authorId).length;
}
}Define tRPC procedures for post operations:
import { Injectable } from '@nestjs/common';
import { TRPCService } from '../trpc/trpc.service';
import { PostsService } from './posts.service';
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
@Injectable()
export class PostsRouter {
constructor(
private readonly trpcService: TRPCService,
private readonly postsService: PostsService,
) {}
createRouter() {
return this.trpcService.router({
// Get all posts (public)
list: this.trpcService.publicProcedure
.input(
z.object({
published: z.boolean().optional(),
authorId: z.string().optional(),
}).optional()
)
.query(async ({ input }) => {
return this.postsService.findAll(input);
}),
// Get single post (public)
getById: this.trpcService.publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const post = await this.postsService.findById(input.id);
if (!post) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Post not found',
});
}
return post;
}),
// Create post (protected)
create: this.trpcService.protectedProcedure
.input(
z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
excerpt: z.string().max(500),
tags: z.array(z.string()).max(10),
published: z.boolean().default(false),
})
)
.mutation(async ({ input, ctx }) => {
return this.postsService.create({
...input,
authorId: ctx.session.userId,
});
}),
// Update post (protected)
update: this.trpcService.protectedProcedure
.input(
z.object({
id: z.string().uuid(),
title: z.string().min(1).max(200).optional(),
content: z.string().min(10).optional(),
excerpt: z.string().max(500).optional(),
tags: z.array(z.string()).max(10).optional(),
published: z.boolean().optional(),
})
)
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
const post = await this.postsService.findById(id);
if (!post) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Post not found',
});
}
// Only author or admin can update
if (post.authorId !== ctx.session.userId && ctx.session.role !== 'admin') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only update your own posts',
});
}
return this.postsService.update(id, data);
}),
// Delete post (protected)
delete: this.trpcService.protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
const post = await this.postsService.findById(input.id);
if (!post) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Post not found',
});
}
// Only author or admin can delete
if (post.authorId !== ctx.session.userId && ctx.session.role !== 'admin') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only delete your own posts',
});
}
await this.postsService.delete(input.id);
return { success: true };
}),
// Get author stats (protected)
myStats: this.trpcService.protectedProcedure
.query(async ({ ctx }) => {
const totalPosts = await this.postsService.countByAuthor(ctx.session.userId);
const posts = await this.postsService.findAll({ authorId: ctx.session.userId });
const publishedPosts = posts.filter(p => p.published).length;
return {
totalPosts,
publishedPosts,
draftPosts: totalPosts - publishedPosts,
};
}),
});
}
}This router demonstrates:
Add comment functionality:
import { Injectable } from '@nestjs/common';
import { TRPCService } from '../trpc/trpc.service';
import { CommentsService } from './comments.service';
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
@Injectable()
export class CommentsRouter {
constructor(
private readonly trpcService: TRPCService,
private readonly commentsService: CommentsService,
) {}
createRouter() {
return this.trpcService.router({
// Get comments for a post (public)
listByPost: this.trpcService.publicProcedure
.input(z.object({ postId: z.string().uuid() }))
.query(async ({ input }) => {
return this.commentsService.findByPostId(input.postId);
}),
// Create comment (protected)
create: this.trpcService.protectedProcedure
.input(
z.object({
postId: z.string().uuid(),
content: z.string().min(1).max(1000),
})
)
.mutation(async ({ input, ctx }) => {
return this.commentsService.create({
...input,
authorId: ctx.session.userId,
});
}),
// Delete comment (protected)
delete: this.trpcService.protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
const comment = await this.commentsService.findById(input.id);
if (!comment) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Comment not found',
});
}
// Only author or admin can delete
if (comment.authorId !== ctx.session.userId && ctx.session.role !== 'admin') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You can only delete your own comments',
});
}
await this.commentsService.delete(input.id);
return { success: true };
}),
});
}
}Create the main app router:
import { Injectable } from '@nestjs/common';
import { TRPCService } from './trpc.service';
import { PostsRouter } from '../posts/posts.router';
import { CommentsRouter } from '../comments/comments.router';
@Injectable()
export class AppRouterService {
constructor(
private readonly trpcService: TRPCService,
private readonly postsRouter: PostsRouter,
private readonly commentsRouter: CommentsRouter,
) {}
createRouter() {
return this.trpcService.router({
posts: this.postsRouter.createRouter(),
comments: this.commentsRouter.createRouter(),
// Health check
health: this.trpcService.publicProcedure.query(() => {
return {
status: 'ok',
timestamp: new Date(),
};
}),
});
}
}
export type AppRouter = ReturnType<AppRouterService['createRouter']>;The AppRouter type is what clients import to get full type safety.
Expose tRPC via HTTP:
import { All, Controller, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { createExpressMiddleware } from '@trpc/server/adapters/express';
import { AppRouterService } from './app.router';
import { TRPCContextService } from './trpc.context';
@Controller('trpc')
export class TRPCController {
private middleware: ReturnType<typeof createExpressMiddleware>;
constructor(
private readonly appRouterService: AppRouterService,
private readonly contextService: TRPCContextService,
) {
const appRouter = this.appRouterService.createRouter();
this.middleware = createExpressMiddleware({
router: appRouter,
createContext: ({ req, res }) => this.contextService.create({ req, res }),
});
}
@All('*')
async handleTRPC(@Req() req: Request, @Res() res: Response) {
return this.middleware(req, res);
}
}Wire everything together:
import { Module } from '@nestjs/common';
import { TRPCService } from './trpc.service';
import { TRPCContextService } from './trpc.context';
import { TRPCController } from './trpc.controller';
import { AppRouterService } from './app.router';
import { PostsModule } from '../posts/posts.module';
import { CommentsModule } from '../comments/comments.module';
@Module({
imports: [PostsModule, CommentsModule],
controllers: [TRPCController],
providers: [TRPCService, TRPCContextService, AppRouterService],
exports: [TRPCService, AppRouterService],
})
export class TRPCModule {}import { Module } from '@nestjs/common';
import { TRPCModule } from './trpc/trpc.module';
@Module({
imports: [TRPCModule],
})
export class AppModule {}Create a type-safe client:
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../src/trpc/app.router';
import superjson from 'superjson';
export const trpc = createTRPCProxyClient<AppRouter>({
transformer: superjson,
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
headers() {
const token = localStorage.getItem('auth_token');
return token ? { authorization: `Bearer ${token}` } : {};
},
}),
],
});Now you have full type safety:
import { trpc } from './trpc.client';
async function exampleUsage() {
// Get all published posts - fully typed!
const posts = await trpc.posts.list.query({ published: true });
console.log('Posts:', posts);
// posts is Post[]
// Get single post
const post = await trpc.posts.getById.query({ id: 'some-uuid' });
console.log('Post:', post);
// post is Post
// Create post (requires authentication)
const newPost = await trpc.posts.create.mutate({
title: 'My First Post',
content: 'This is the content of my first post.',
excerpt: 'A brief excerpt',
tags: ['typescript', 'trpc'],
published: true,
});
console.log('Created:', newPost);
// newPost is Post
// Update post
const updated = await trpc.posts.update.mutate({
id: newPost.id,
title: 'Updated Title',
});
console.log('Updated:', updated);
// Get my stats
const stats = await trpc.posts.myStats.query();
console.log('Stats:', stats);
// stats is { totalPosts: number, publishedPosts: number, draftPosts: number }
// Add comment
const comment = await trpc.comments.create.mutate({
postId: newPost.id,
content: 'Great post!',
});
console.log('Comment:', comment);
// Get comments for post
const comments = await trpc.comments.listByPost.query({ postId: newPost.id });
console.log('Comments:', comments);
}
// Error handling
async function errorHandling() {
try {
await trpc.posts.getById.query({ id: 'invalid-id' });
} catch (error) {
if (error.data?.code === 'NOT_FOUND') {
console.log('Post not found');
}
}
}Notice:
Use tRPC with React Query:
npm install @trpc/react-query @tanstack/react-queryimport { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../src/trpc/app.router';
export const trpc = createTRPCReact<AppRouter>();import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from './trpc-react';
import { useState } from 'react';
import superjson from 'superjson';
function App() {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
transformer: superjson,
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
headers() {
const token = localStorage.getItem('auth_token');
return token ? { authorization: `Bearer ${token}` } : {};
},
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<PostsList />
</QueryClientProvider>
</trpc.Provider>
);
}
function PostsList() {
// Fully typed query with React Query features
const { data: posts, isLoading, error } = trpc.posts.list.useQuery({
published: true,
});
// Mutation with optimistic updates
const utils = trpc.useUtils();
const createPost = trpc.posts.create.useMutation({
onSuccess: () => {
// Invalidate and refetch
utils.posts.list.invalidate();
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>Posts</h1>
{posts?.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<time>{post.createdAt.toLocaleDateString()}</time>
</article>
))}
<button
onClick={() =>
createPost.mutate({
title: 'New Post',
content: 'Content here',
excerpt: 'Excerpt',
tags: [],
published: true,
})
}
>
Create Post
</button>
</div>
);
}
export default App;Benefits:
tRPC automatically batches requests made within 10ms:
// These three requests are batched into one HTTP call
const [posts, comments, stats] = await Promise.all([
trpc.posts.list.query(),
trpc.comments.listByPost.query({ postId: 'some-id' }),
trpc.posts.myStats.query(),
]);Add real-time capabilities:
npm install ws @trpc/serverimport { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';
const ee = new EventEmitter();
export const subscriptionsRouter = trpcService.router({
onPostCreated: trpcService.publicProcedure
.subscription(() => {
return observable<Post>((emit) => {
const onPost = (post: Post) => {
emit.next(post);
};
ee.on('post:created', onPost);
return () => {
ee.off('post:created', onPost);
};
});
}),
onCommentAdded: trpcService.publicProcedure
.input(z.object({ postId: z.string() }))
.subscription(({ input }) => {
return observable<Comment>((emit) => {
const onComment = (comment: Comment) => {
if (comment.postId === input.postId) {
emit.next(comment);
}
};
ee.on('comment:added', onComment);
return () => {
ee.off('comment:added', onComment);
};
});
}),
});
// Emit events when data changes
export function emitPostCreated(post: Post) {
ee.emit('post:created', post);
}
export function emitCommentAdded(comment: Comment) {
ee.emit('comment:added', comment);
}Client usage:
function RealtimePosts() {
const [posts, setPosts] = useState<Post[]>([]);
trpc.subscriptions.onPostCreated.useSubscription(undefined, {
onData(post) {
setPosts(prev => [post, ...prev]);
},
});
return (
<div>
{posts.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
);
}Create reusable middleware chains:
// Logging middleware
const loggerMiddleware = trpcService.trpc.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
console.log(`${type} ${path} - ${duration}ms`);
return result;
});
// Rate limiting middleware
const rateLimitMiddleware = trpcService.trpc.middleware(async ({ ctx, next }) => {
const key = ctx.session?.userId || ctx.req.ip;
const limit = await checkRateLimit(key);
if (!limit.allowed) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded',
});
}
return next();
});
// Combine middleware
const rateLimitedProcedure = trpcService.publicProcedure
.use(loggerMiddleware)
.use(rateLimitMiddleware);Transform inputs before validation:
const createPostWithSlug = trpcService.protectedProcedure
.input(
z.object({
title: z.string().transform(title => ({
title,
slug: title.toLowerCase().replace(/\s+/g, '-'),
})),
content: z.string(),
})
)
.mutation(async ({ input, ctx }) => {
// input.title is now { title: string, slug: string }
return postsService.create({
...input.title,
content: input.content,
authorId: ctx.session.userId,
});
});Wrong: Returning database models directly
const getUser = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
// Returns internal DB model with password hash!
return db.user.findUnique({ where: { id: input.id } });
});Right: Use DTOs to control what's exposed
const getUser = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
// Return only safe fields
return {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt,
};
});Wrong: Manual validation
const createPost = protectedProcedure
.input(z.any()) // Don't do this!
.mutation(async ({ input }) => {
if (!input.title || input.title.length < 1) {
throw new Error('Invalid title');
}
// More manual checks...
});Right: Let Zod handle validation
const createPost = protectedProcedure
.input(
z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()).max(10),
})
)
.mutation(async ({ input }) => {
// input is validated and typed
return postsService.create(input);
});Wrong: Generic errors
throw new Error('Something went wrong');Right: Use proper tRPC error codes
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Post not found',
cause: originalError,
});This ensures proper HTTP status codes and typed errors on the client.
Wrong: Assuming session always exists
const getProfile = publicProcedure.query(async ({ ctx }) => {
// ctx.session might be null!
return db.user.findUnique({ where: { id: ctx.session.userId } });
});Right: Use protected procedures or check explicitly
const getProfile = protectedProcedure.query(async ({ ctx }) => {
// ctx.session is guaranteed to exist
return db.user.findUnique({ where: { id: ctx.session.userId } });
});Wrong: Returning everything
const getPost = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
// Returns post with all comments, all author data, etc.
return db.post.findUnique({
where: { id: input.id },
include: {
comments: { include: { author: true } },
author: { include: { posts: true } },
},
});
});Right: Create specific procedures for different needs
const getPost = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.post.findUnique({ where: { id: input.id } });
});
const getPostWithComments = publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.post.findUnique({
where: { id: input.id },
include: { comments: true },
});
});Keep routers focused and organized:
src/
├── trpc/
│ ├── trpc.service.ts
│ ├── trpc.context.ts
│ └── app.router.ts
├── users/
│ ├── users.service.ts
│ ├── users.router.ts
│ └── users.module.ts
├── posts/
│ ├── posts.service.ts
│ ├── posts.router.ts
│ └── posts.module.ts
└── comments/
├── comments.service.ts
├── comments.router.ts
└── comments.module.tsDefine reusable schemas:
import { z } from 'zod';
export const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
excerpt: z.string().max(500),
tags: z.array(z.string()).max(10),
published: z.boolean().default(false),
});
export const updatePostSchema = createPostSchema.partial().extend({
id: z.string().uuid(),
});
export const postIdSchema = z.object({
id: z.string().uuid(),
});
// Use in procedures
const createPost = protectedProcedure
.input(createPostSchema)
.mutation(async ({ input, ctx }) => {
// ...
});Log all procedure calls:
const loggingMiddleware = trpcService.trpc.middleware(async ({ path, type, next, ctx }) => {
const start = Date.now();
try {
const result = await next();
const duration = Date.now() - start;
logger.log({
type,
path,
duration,
userId: ctx.session?.userId,
success: true,
});
return result;
} catch (error) {
const duration = Date.now() - start;
logger.error({
type,
path,
duration,
userId: ctx.session?.userId,
error: error.message,
success: false,
});
throw error;
}
});Configure batching for better performance:
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
maxBatchSize: 10, // Batch up to 10 requests
// Requests within 10ms are batched together
}),
],
});Use React Query's caching effectively:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
cacheTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
retry: 1,
},
},
});
// Prefetch data
await queryClient.prefetchQuery({
queryKey: ['posts', 'list'],
queryFn: () => trpc.posts.list.query(),
});Create error boundaries and handlers:
function PostsList() {
const { data, error, isLoading } = trpc.posts.list.useQuery();
if (isLoading) return <Spinner />;
if (error) {
if (error.data?.code === 'UNAUTHORIZED') {
return <LoginPrompt />;
}
if (error.data?.code === 'FORBIDDEN') {
return <AccessDenied />;
}
return <ErrorMessage message={error.message} />;
}
return <PostsGrid posts={data} />;
}Despite its strengths, tRPC is the wrong choice in many scenarios:
If you're building a public API for third-party developers, tRPC is unsuitable because:
Why: tRPC requires TypeScript on both ends. External developers might use Python, Go, Java, or other languages. REST or GraphQL provide language-agnostic contracts.
Alternative: Use REST with OpenAPI or GraphQL for public APIs.
If your architecture includes services in multiple languages, tRPC won't work:
Why: tRPC's type inference only works within TypeScript. A Python service can't consume tRPC types.
Alternative: Use gRPC for high-performance microservices or REST for simplicity.
React Native works with tRPC, but native iOS (Swift) or Android (Kotlin) apps cannot:
Why: Native apps can't import TypeScript types. They need language-agnostic API contracts.
Alternative: Use REST or GraphQL with code generation for native platforms.
If frontend and backend are in separate repositories, tRPC loses its main advantage:
Why: Type sharing requires importing types from the backend package. Separate repos make this cumbersome.
Alternative: Use GraphQL with code generation or REST with OpenAPI.
When frontend and backend teams work independently, tRPC can create coupling issues:
Why: Backend changes immediately affect frontend types. This tight coupling can slow down independent team velocity.
Alternative: Use GraphQL or REST with versioned contracts for team independence.
tRPC adds minimal overhead, but in extreme cold start scenarios, every millisecond counts:
Why: While tRPC is lightweight, REST with minimal dependencies might start faster.
Alternative: Use simple REST endpoints for latency-critical serverless functions.
Let's build a comprehensive task management system that demonstrates tRPC's strengths in a realistic scenario.
You're building a task management SaaS with:
export enum TaskStatus {
TODO = 'TODO',
IN_PROGRESS = 'IN_PROGRESS',
IN_REVIEW = 'IN_REVIEW',
DONE = 'DONE',
}
export enum TaskPriority {
LOW = 'LOW',
MEDIUM = 'MEDIUM',
HIGH = 'HIGH',
URGENT = 'URGENT',
}
export interface Task {
id: string;
title: string;
description: string;
status: TaskStatus;
priority: TaskPriority;
projectId: string;
assigneeId: string | null;
creatorId: string;
dueDate: Date | null;
completedAt: Date | null;
createdAt: Date;
updatedAt: Date;
tags: string[];
}
export interface Project {
id: string;
name: string;
description: string;
ownerId: string;
memberIds: string[];
createdAt: Date;
updatedAt: Date;
}
export interface Activity {
id: string;
type: 'task_created' | 'task_updated' | 'task_completed' | 'task_assigned';
taskId: string;
userId: string;
metadata: Record<string, any>;
createdAt: Date;
}import { Injectable, Logger } from '@nestjs/common';
import { Task, TaskStatus, TaskPriority } from '../models/task.model';
import { randomUUID } from 'crypto';
import { EventEmitter } from 'events';
@Injectable()
export class TasksService {
private readonly logger = new Logger(TasksService.name);
private readonly tasks = new Map<string, Task>();
private readonly eventEmitter = new EventEmitter();
async findAll(filters?: {
projectId?: string;
assigneeId?: string;
status?: TaskStatus;
priority?: TaskPriority;
}): Promise<Task[]> {
let tasks = Array.from(this.tasks.values());
if (filters?.projectId) {
tasks = tasks.filter(t => t.projectId === filters.projectId);
}
if (filters?.assigneeId) {
tasks = tasks.filter(t => t.assigneeId === filters.assigneeId);
}
if (filters?.status) {
tasks = tasks.filter(t => t.status === filters.status);
}
if (filters?.priority) {
tasks = tasks.filter(t => t.priority === filters.priority);
}
return tasks.sort((a, b) => {
// Sort by priority, then due date
const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
const priorityDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
if (priorityDiff !== 0) return priorityDiff;
if (a.dueDate && b.dueDate) {
return a.dueDate.getTime() - b.dueDate.getTime();
}
return b.createdAt.getTime() - a.createdAt.getTime();
});
}
async findById(id: string): Promise<Task | null> {
return this.tasks.get(id) || null;
}
async create(data: {
title: string;
description: string;
projectId: string;
creatorId: string;
priority: TaskPriority;
assigneeId?: string;
dueDate?: Date;
tags: string[];
}): Promise<Task> {
const task: Task = {
id: randomUUID(),
...data,
status: TaskStatus.TODO,
assigneeId: data.assigneeId || null,
dueDate: data.dueDate || null,
completedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
this.tasks.set(task.id, task);
this.logger.log(`Task created: ${task.id}`);
// Emit event for real-time updates
this.eventEmitter.emit('task:created', task);
return task;
}
async update(
id: string,
data: Partial<Omit<Task, 'id' | 'creatorId' | 'createdAt'>>
): Promise<Task> {
const task = this.tasks.get(id);
if (!task) {
throw new Error('Task not found');
}
const updated: Task = {
...task,
...data,
updatedAt: new Date(),
};
// Auto-set completedAt when status changes to DONE
if (data.status === TaskStatus.DONE && task.status !== TaskStatus.DONE) {
updated.completedAt = new Date();
}
this.tasks.set(id, updated);
this.logger.log(`Task updated: ${id}`);
// Emit event for real-time updates
this.eventEmitter.emit('task:updated', updated);
return updated;
}
async delete(id: string): Promise<void> {
const deleted = this.tasks.delete(id);
if (!deleted) {
throw new Error('Task not found');
}
this.logger.log(`Task deleted: ${id}`);
this.eventEmitter.emit('task:deleted', { id });
}
async getStatsByProject(projectId: string): Promise<{
total: number;
byStatus: Record<TaskStatus, number>;
byPriority: Record<TaskPriority, number>;
overdue: number;
}> {
const tasks = await this.findAll({ projectId });
const now = new Date();
return {
total: tasks.length,
byStatus: {
[TaskStatus.TODO]: tasks.filter(t => t.status === TaskStatus.TODO).length,
[TaskStatus.IN_PROGRESS]: tasks.filter(t => t.status === TaskStatus.IN_PROGRESS).length,
[TaskStatus.IN_REVIEW]: tasks.filter(t => t.status === TaskStatus.IN_REVIEW).length,
[TaskStatus.DONE]: tasks.filter(t => t.status === TaskStatus.DONE).length,
},
byPriority: {
[TaskPriority.LOW]: tasks.filter(t => t.priority === TaskPriority.LOW).length,
[TaskPriority.MEDIUM]: tasks.filter(t => t.priority === TaskPriority.MEDIUM).length,
[TaskPriority.HIGH]: tasks.filter(t => t.priority === TaskPriority.HIGH).length,
[TaskPriority.URGENT]: tasks.filter(t => t.priority === TaskPriority.URGENT).length,
},
overdue: tasks.filter(t =>
t.dueDate &&
t.dueDate < now &&
t.status !== TaskStatus.DONE
).length,
};
}
onTaskCreated(callback: (task: Task) => void) {
this.eventEmitter.on('task:created', callback);
}
onTaskUpdated(callback: (task: Task) => void) {
this.eventEmitter.on('task:updated', callback);
}
onTaskDeleted(callback: (data: { id: string }) => void) {
this.eventEmitter.on('task:deleted', callback);
}
}import { Injectable } from '@nestjs/common';
import { TRPCService } from '../trpc/trpc.service';
import { TasksService } from './tasks.service';
import { ProjectsService } from '../projects/projects.service';
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { TaskStatus, TaskPriority } from '../models/task.model';
import { observable } from '@trpc/server/observable';
@Injectable()
export class TasksRouter {
constructor(
private readonly trpcService: TRPCService,
private readonly tasksService: TasksService,
private readonly projectsService: ProjectsService,
) {}
createRouter() {
return this.trpcService.router({
// List tasks with filters
list: this.trpcService.protectedProcedure
.input(
z.object({
projectId: z.string().uuid().optional(),
assigneeId: z.string().uuid().optional(),
status: z.nativeEnum(TaskStatus).optional(),
priority: z.nativeEnum(TaskPriority).optional(),
}).optional()
)
.query(async ({ input, ctx }) => {
// If projectId provided, verify user has access
if (input?.projectId) {
const project = await this.projectsService.findById(input.projectId);
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found',
});
}
const hasAccess = project.ownerId === ctx.session.userId ||
project.memberIds.includes(ctx.session.userId);
if (!hasAccess) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this project',
});
}
}
return this.tasksService.findAll(input);
}),
// Get single task
getById: this.trpcService.protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const task = await this.tasksService.findById(input.id);
if (!task) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Task not found',
});
}
// Verify access through project
const project = await this.projectsService.findById(task.projectId);
const hasAccess = project.ownerId === ctx.session.userId ||
project.memberIds.includes(ctx.session.userId);
if (!hasAccess) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this task',
});
}
return task;
}),
// Create task
create: this.trpcService.protectedProcedure
.input(
z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000),
projectId: z.string().uuid(),
priority: z.nativeEnum(TaskPriority).default(TaskPriority.MEDIUM),
assigneeId: z.string().uuid().optional(),
dueDate: z.date().optional(),
tags: z.array(z.string()).max(10).default([]),
})
)
.mutation(async ({ input, ctx }) => {
// Verify project access
const project = await this.projectsService.findById(input.projectId);
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found',
});
}
const hasAccess = project.ownerId === ctx.session.userId ||
project.memberIds.includes(ctx.session.userId);
if (!hasAccess) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this project',
});
}
// Verify assignee is project member
if (input.assigneeId) {
const isValidAssignee = project.ownerId === input.assigneeId ||
project.memberIds.includes(input.assigneeId);
if (!isValidAssignee) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Assignee must be a project member',
});
}
}
return this.tasksService.create({
...input,
creatorId: ctx.session.userId,
});
}),
// Update task
update: this.trpcService.protectedProcedure
.input(
z.object({
id: z.string().uuid(),
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
status: z.nativeEnum(TaskStatus).optional(),
priority: z.nativeEnum(TaskPriority).optional(),
assigneeId: z.string().uuid().nullable().optional(),
dueDate: z.date().nullable().optional(),
tags: z.array(z.string()).max(10).optional(),
})
)
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
const task = await this.tasksService.findById(id);
if (!task) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Task not found',
});
}
// Verify project access
const project = await this.projectsService.findById(task.projectId);
const hasAccess = project.ownerId === ctx.session.userId ||
project.memberIds.includes(ctx.session.userId);
if (!hasAccess) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this task',
});
}
return this.tasksService.update(id, data);
}),
// Delete task
delete: this.trpcService.protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
const task = await this.tasksService.findById(input.id);
if (!task) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Task not found',
});
}
// Only creator or project owner can delete
const project = await this.projectsService.findById(task.projectId);
const canDelete = task.creatorId === ctx.session.userId ||
project.ownerId === ctx.session.userId;
if (!canDelete) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only task creator or project owner can delete tasks',
});
}
await this.tasksService.delete(input.id);
return { success: true };
}),
// Get project statistics
getProjectStats: this.trpcService.protectedProcedure
.input(z.object({ projectId: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const project = await this.projectsService.findById(input.projectId);
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found',
});
}
const hasAccess = project.ownerId === ctx.session.userId ||
project.memberIds.includes(ctx.session.userId);
if (!hasAccess) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this project',
});
}
return this.tasksService.getStatsByProject(input.projectId);
}),
// Real-time subscription for task updates
onTaskUpdate: this.trpcService.protectedProcedure
.input(z.object({ projectId: z.string().uuid() }))
.subscription(async ({ input, ctx }) => {
// Verify project access
const project = await this.projectsService.findById(input.projectId);
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found',
});
}
const hasAccess = project.ownerId === ctx.session.userId ||
project.memberIds.includes(ctx.session.userId);
if (!hasAccess) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this project',
});
}
return observable<{ type: string; task: any }>((emit) => {
const onCreated = (task: any) => {
if (task.projectId === input.projectId) {
emit.next({ type: 'created', task });
}
};
const onUpdated = (task: any) => {
if (task.projectId === input.projectId) {
emit.next({ type: 'updated', task });
}
};
const onDeleted = (data: { id: string }) => {
emit.next({ type: 'deleted', task: { id: data.id } });
};
this.tasksService.onTaskCreated(onCreated);
this.tasksService.onTaskUpdated(onUpdated);
this.tasksService.onTaskDeleted(onDeleted);
return () => {
// Cleanup listeners
};
});
}),
});
}
}This router demonstrates:
Build a complete task management UI:
import { trpc } from '../trpc-react';
import { TaskStatus, TaskPriority } from '../../src/models/task.model';
import { useState } from 'react';
export function TaskBoard({ projectId }: { projectId: string }) {
const utils = trpc.useUtils();
// Fetch tasks
const { data: tasks, isLoading } = trpc.tasks.list.useQuery({ projectId });
// Fetch project stats
const { data: stats } = trpc.tasks.getProjectStats.useQuery({ projectId });
// Subscribe to real-time updates
trpc.tasks.onTaskUpdate.useSubscription(
{ projectId },
{
onData(update) {
// Invalidate queries to refetch
utils.tasks.list.invalidate();
utils.tasks.getProjectStats.invalidate();
},
}
);
// Mutations
const createTask = trpc.tasks.create.useMutation({
onSuccess: () => {
utils.tasks.list.invalidate();
utils.tasks.getProjectStats.invalidate();
},
});
const updateTask = trpc.tasks.update.useMutation({
onMutate: async (variables) => {
// Optimistic update
await utils.tasks.list.cancel();
const previousTasks = utils.tasks.list.getData({ projectId });
utils.tasks.list.setData({ projectId }, (old) =>
old?.map(task =>
task.id === variables.id
? { ...task, ...variables }
: task
)
);
return { previousTasks };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousTasks) {
utils.tasks.list.setData({ projectId }, context.previousTasks);
}
},
onSettled: () => {
utils.tasks.list.invalidate();
},
});
if (isLoading) return <div>Loading tasks...</div>;
const tasksByStatus = {
[TaskStatus.TODO]: tasks?.filter(t => t.status === TaskStatus.TODO) || [],
[TaskStatus.IN_PROGRESS]: tasks?.filter(t => t.status === TaskStatus.IN_PROGRESS) || [],
[TaskStatus.IN_REVIEW]: tasks?.filter(t => t.status === TaskStatus.IN_REVIEW) || [],
[TaskStatus.DONE]: tasks?.filter(t => t.status === TaskStatus.DONE) || [],
};
return (
<div className="task-board">
{/* Stats Dashboard */}
<div className="stats">
<div>Total: {stats?.total}</div>
<div>Overdue: {stats?.overdue}</div>
<div>
Urgent: {stats?.byPriority[TaskPriority.URGENT]}
</div>
</div>
{/* Kanban Board */}
<div className="columns">
{Object.entries(tasksByStatus).map(([status, statusTasks]) => (
<div key={status} className="column">
<h3>{status.replace('_', ' ')}</h3>
<div className="tasks">
{statusTasks.map(task => (
<TaskCard
key={task.id}
task={task}
onUpdate={(data) =>
updateTask.mutate({ id: task.id, ...data })
}
/>
))}
</div>
</div>
))}
</div>
{/* Create Task Form */}
<CreateTaskForm
projectId={projectId}
onSubmit={(data) => createTask.mutate(data)}
/>
</div>
);
}
function TaskCard({ task, onUpdate }: any) {
const priorityColors = {
[TaskPriority.LOW]: 'gray',
[TaskPriority.MEDIUM]: 'blue',
[TaskPriority.HIGH]: 'orange',
[TaskPriority.URGENT]: 'red',
};
return (
<div className="task-card" style={{ borderLeft: `4px solid ${priorityColors[task.priority]}` }}>
<h4>{task.title}</h4>
<p>{task.description}</p>
<div className="task-meta">
{task.dueDate && (
<span>Due: {new Date(task.dueDate).toLocaleDateString()}</span>
)}
{task.tags.map(tag => (
<span key={tag} className="tag">{tag}</span>
))}
</div>
<select
value={task.status}
onChange={(e) => onUpdate({ status: e.target.value })}
>
{Object.values(TaskStatus).map(status => (
<option key={status} value={status}>
{status.replace('_', ' ')}
</option>
))}
</select>
</div>
);
}
function CreateTaskForm({ projectId, onSubmit }: any) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState(TaskPriority.MEDIUM);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
projectId,
title,
description,
priority,
tags: [],
});
setTitle('');
setDescription('');
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Task title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<textarea
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
required
/>
<select value={priority} onChange={(e) => setPriority(e.target.value as TaskPriority)}>
{Object.values(TaskPriority).map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
<button type="submit">Create Task</button>
</form>
);
}This implementation showcases:
tRPC automatically deduplicates identical requests:
// These three identical requests result in only one network call
const [result1, result2, result3] = await Promise.all([
trpc.posts.getById.query({ id: '123' }),
trpc.posts.getById.query({ id: '123' }),
trpc.posts.getById.query({ id: '123' }),
]);Prefetch data before it's needed:
function ProjectsList() {
const utils = trpc.useUtils();
return (
<div>
{projects.map(project => (
<div
key={project.id}
onMouseEnter={() => {
// Prefetch tasks when hovering
utils.tasks.list.prefetch({ projectId: project.id });
}}
>
<Link to={`/projects/${project.id}`}>{project.name}</Link>
</div>
))}
</div>
);
}Implement pagination efficiently:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
trpc.tasks.list.useInfiniteQuery(
{ projectId },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
return (
<div>
{data?.pages.map((page) =>
page.tasks.map((task) => <TaskCard key={task.id} task={task} />)
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);import { TasksRouter } from '../src/tasks/tasks.router';
import { TasksService } from '../src/tasks/tasks.service';
import { TRPCService } from '../src/trpc/trpc.service';
import { TRPCError } from '@trpc/server';
describe('TasksRouter', () => {
let router: TasksRouter;
let tasksService: TasksService;
beforeEach(() => {
tasksService = new TasksService();
const trpcService = new TRPCService();
router = new TasksRouter(trpcService, tasksService);
});
describe('create', () => {
it('should create a task successfully', async () => {
const caller = router.createRouter().createCaller({
session: { userId: 'user-1', email: 'test@example.com', role: 'user' },
req: {} as any,
res: {} as any,
});
const task = await caller.create({
title: 'Test Task',
description: 'Test description',
projectId: 'project-1',
priority: 'MEDIUM',
tags: [],
});
expect(task).toMatchObject({
title: 'Test Task',
description: 'Test description',
creatorId: 'user-1',
});
});
it('should throw error for invalid input', async () => {
const caller = router.createRouter().createCaller({
session: { userId: 'user-1', email: 'test@example.com', role: 'user' },
req: {} as any,
res: {} as any,
});
await expect(
caller.create({
title: '', // Invalid: empty title
description: 'Test',
projectId: 'project-1',
priority: 'MEDIUM',
tags: [],
})
).rejects.toThrow();
});
});
});tRPC represents a fundamental shift in how we think about API development in TypeScript monorepos. By leveraging TypeScript's type system, it eliminates entire categories of bugs and dramatically improves developer experience.
The key insights:
tRPC excels when you control both client and server, work in TypeScript, and value type safety over protocol flexibility. Its automatic type inference, minimal boilerplate, and seamless integration with React Query make it ideal for full-stack TypeScript applications.
tRPC struggles with public APIs, multi-language environments, and polyrepo architectures. When you need language-agnostic contracts or team independence, REST or GraphQL remain better choices.
The NestJS implementation demonstrates that tRPC can integrate with enterprise frameworks while maintaining its simplicity. By understanding tRPC's strengths and limitations, you can make informed decisions about when it's the right tool for your project.
If you're building a TypeScript monorepo with React or Next.js, tRPC deserves serious consideration. The productivity gains and type safety benefits often outweigh the constraints of TypeScript-only development.