tRPC: बिना Ceremony के End-to-End Type Safety
tRPC API contract problem कैसे eliminate करता है, Next.js App Router के साथ कैसे काम करता है, auth middleware, file uploads, और subscriptions handle करता है। Scratch से एक real-world tRPC setup।
आपको पता है drill। API response में field name बदलते हो। Backend type update करते हो। Deploy करते हो। फिर frontend production में टूटता है क्योंकि कोई Dashboard.tsx की line 247 पर fetch call update करना भूल गया। Field अब undefined है, component blank render होता है, और error tracking रात 2 बजे light up होती है।
यह API contract problem है। Technology problem नहीं है। Coordination problem है। और कितनी भी Swagger docs या GraphQL schemas इस fact को fix नहीं करेंगी कि frontend और backend types silently drift apart हो सकते हैं।
tRPC इसे fix करता है drift होने ही नहीं देकर। कोई schema file नहीं। कोई code generation step नहीं। कोई अलग contract maintain करने की ज़रूरत नहीं। Server पर TypeScript function लिखो, और client compile time पर exact input और output types जानता है। Field rename करो, frontend compile नहीं होगा जब तक fix नहीं करते।
यही promise है। दिखाता हूं कैसे यह actually काम करता है, कहां चमकता है, और कहां इसे बिल्कुल इस्तेमाल नहीं करना चाहिए।
Problem, ज़्यादा Precisely#
देखते हैं ज़्यादातर teams आज APIs कैसे build करती हैं।
REST + OpenAPI: Endpoints लिखो। शायद Swagger annotations add करो। शायद OpenAPI spec से client SDK generate करो। लेकिन spec एक अलग artifact है। Stale हो सकता है। Generation step CI pipeline में एक और चीज़ है जो break या भूली जा सकती है। और generated types अक्सर बदसूरत होते हैं — deeply nested paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"] monsters।
GraphQL: Better type safety, लेकिन बहुत ceremony। Schema SDL में लिखो। Resolvers लिखो। Schema से types generate करो। Client पर queries लिखो। Queries से types generate करो। कम से कम दो code generation steps, एक schema file, और एक build step जो सबको याद रखना होता है run करना। जो team frontend और backend दोनों control करती है, उसके लिए बहुत infrastructure है एक ऐसे problem के लिए जिसका simpler solution है।
Manual fetch calls: सबसे common approach और सबसे dangerous। fetch("/api/users") लिखो, result को User[] cast करो, और best hope करो। कोई compile-time safety नहीं। Type assertion एक झूठ है जो आप TypeScript को बोलते हो।
// वो झूठ जो हर frontend developer ने बोला है
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // 🙏 उम्मीद है सही हैtRPC बिल्कुल अलग approach लेता है। API को अलग format में describe करके types generate करने की बजाय, server पर plain TypeScript functions लिखो और उनके types directly client में import करो। कोई generation step नहीं। कोई schema file नहीं। कोई drift नहीं।
Core Concepts#
कुछ भी setup करने से पहले, mental model समझ लेते हैं।
Router#
tRPC router procedures का एक collection है जो एक साथ grouped हैं। MVC में controller जैसा सोचो, सिवाय इसके कि यह बस एक plain object है type inference baked in के साथ।
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const appRouter = t.router({
user: t.router({
list: t.procedure.query(/* ... */),
byId: t.procedure.input(/* ... */).query(/* ... */),
create: t.procedure.input(/* ... */).mutation(/* ... */),
}),
post: t.router({
list: t.procedure.query(/* ... */),
publish: t.procedure.input(/* ... */).mutation(/* ... */),
}),
});
export type AppRouter = typeof appRouter;वो AppRouter type export पूरा magic trick है। Client यह type import करता है — runtime code नहीं, सिर्फ type — और हर procedure के लिए full autocompletion और type checking मिलती है।
Procedure#
Procedure एक single endpoint है। तीन तरह के होते हैं:
- Query: Read operations। HTTP GET semantics पर map होता है। TanStack Query cache करता है।
- Mutation: Write operations। HTTP POST पर map होता है। Cached नहीं।
- Subscription: Real-time streams। WebSockets इस्तेमाल करता है।
Context#
Context request-scoped data है जो हर procedure को available है। Database connections, authenticated user, request headers — Express के req object में जो डालते वो सब यहां जाता है।
Middleware#
Middleware context transform करता है या access gate करता है। सबसे common pattern एक auth middleware है जो valid session check करता है और ctx.user add करता है।
Type Inference Chain#
यह critical mental model है। जब procedure ऐसे define करते हो:
t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
// input typed है { id: string } के रूप में
return db.user.findUnique({ where: { id: input.id } });
});Return type client तक flow करता है। अगर db.user.findUnique User | null return करता है, client का useQuery hook data को User | null typed रखेगा। कोई manual typing नहीं। कोई casting नहीं। End-to-end inferred है।
Next.js App Router के साथ Setup#
Scratch से build करते हैं। मान लो आपके पास Next.js 14+ project है App Router के साथ।
Dependencies Install करो#
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zodStep 1: Server पर tRPC Initialize करो#
अपना tRPC instance create करो और context type define करो।
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import superjson from "superjson";
import { ZodError } from "zod";
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
const session = await getSession(opts.req);
return {
session,
db: prisma,
req: opts.req,
};
}
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const createCallerFactory = t.createCallerFactory;
export const router = t.router;
export const publicProcedure = t.procedure;कुछ बातें ध्यान देने योग्य:
superjsontransformer: tRPC default में data JSON serialize करता है, यानीDateobjects,Map,Setजैसे non-JSON types खो जाते हैं। superjson preserve करता है।- Error formatter: Zod validation errors response से attach करता है ताकि client field-level errors दिखा सके।
createTRPCContext: यह function हर request पर run होता है। यहां session parse करते हो, database connection set up करते हो, और context object build करते हो।
Step 2: Router Define करो#
// src/server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
export const userRouter = router({
me: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: ctx.user.id },
select: {
id: true,
name: true,
email: true,
image: true,
createdAt: true,
},
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const updated = await ctx.db.user.update({
where: { id: ctx.user.id },
data: {
name: input.name,
bio: input.bio,
},
});
return updated;
}),
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id },
select: {
id: true,
name: true,
image: true,
bio: true,
},
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
});// src/server/routers/_app.ts
import { router } from "../trpc";
import { userRouter } from "./user";
import { postRouter } from "./post";
import { notificationRouter } from "./notification";
export const appRouter = router({
user: userRouter,
post: postRouter,
notification: notificationRouter,
});
export type AppRouter = typeof appRouter;Step 3: Next.js Route Handler से Expose करो#
App Router में, tRPC standard Route Handler के रूप में चलता है। कोई custom server नहीं, कोई special Next.js plugin नहीं।
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers/_app";
import { createTRPCContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: createTRPCContext,
onError:
process.env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
);
}
: undefined,
});
export { handler as GET, handler as POST };बस इतना। GET और POST दोनों handle होते हैं। Queries GET से जाती हैं (URL-encoded input के साथ), mutations POST से जाती हैं।
Step 4: Client Set Up करो#
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();Note: हम AppRouter को सिर्फ type के रूप में import करते हैं। कोई server code client bundle में leak नहीं होता।
Step 5: Provider Setup#
// src/components/providers/TRPCProvider.tsx
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
import { trpc } from "@/lib/trpc";
import superjson from "superjson";
function getBaseUrl() {
if (typeof window !== "undefined") return "";
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
},
},
})
);
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}// src/app/layout.tsx (relevant part)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}Step 6: Components में इस्तेमाल करो#
// src/app/dashboard/page.tsx
"use client";
import { trpc } from "@/lib/trpc";
export default function DashboardPage() {
const { data: user, isLoading, error } = trpc.user.me.useQuery();
if (isLoading) return <DashboardSkeleton />;
if (error) return <ErrorDisplay message={error.message} />;
return (
<div>
<h1>Welcome back, {user.name}</h1>
<p>Member since {user.createdAt.toLocaleDateString()}</p>
</div>
);
}वो user.name पूरी तरह typed है। अगर इसे user.nme गलत लिखो, TypeScript तुरंत पकड़ता है। अगर server पर name की बजाय displayName return करने लगो, हर client usage compile error दिखाएगा। कोई runtime surprises नहीं।
Context और Middleware#
Context और middleware वो जगह है जहां tRPC "neat type trick" से "production-ready framework" बन जाता है।
Context बनाना#
Context function हर request पर run होता है। यहां एक real-world version है:
// src/server/trpc.ts
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
const session = await getServerSession(authOptions);
return {
session,
user: session?.user ?? null,
db: prisma,
headers: Object.fromEntries(opts.req.headers),
};
}
type Context = Awaited<ReturnType<typeof createTRPCContext>>;Auth Middleware#
सबसे common middleware pattern public और protected procedures अलग करना है:
// src/server/trpc.ts
const isAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to perform this action",
});
}
return next({
ctx: {
// Context type override करो — user अब nullable नहीं है
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);इस middleware के बाद, किसी भी protectedProcedure में ctx.user guaranteed non-null है। Type system यह enforce करता है। बिना TypeScript complain किए public procedure में ctx.user.id access नहीं कर सकते।
Role-Based Middleware#
ज़्यादा granular access control के लिए middleware compose कर सकते हो:
const isAdmin = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (ctx.user.role !== "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required",
});
}
return next({
ctx: {
user: ctx.user,
},
});
});
export const adminProcedure = t.procedure.use(isAdmin);Logging Middleware#
Middleware सिर्फ auth के लिए नहीं है। यहां एक performance logging middleware है:
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
if (duration > 1000) {
console.warn(`⚠️ Slow ${type} ${path}: ${duration}ms`);
}
return result;
});
// सभी procedures पर apply करो
export const publicProcedure = t.procedure.use(loggerMiddleware);Rate Limiting Middleware#
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(20, "10 s"),
});
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
const ip = ctx.headers["x-forwarded-for"] ?? "unknown";
const { success, remaining } = await ratelimit.limit(ip);
if (!success) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Rate limit exceeded. Try again later.`,
});
}
return next();
});Zod के साथ Input Validation#
tRPC input validation के लिए Zod इस्तेमाल करता है। यह optional decoration नहीं है — यही वो mechanism है जो ensure करता है कि inputs client और server दोनों पर safe हैं।
Basic Validation#
const postRouter = router({
create: protectedProcedure
.input(
z.object({
title: z.string().min(1, "Title is required").max(200, "Title too long"),
content: z.string().min(10, "Content must be at least 10 characters"),
categoryId: z.string().uuid("Invalid category ID"),
tags: z.array(z.string()).max(5, "Maximum 5 tags").default([]),
published: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
// input पूरी तरह typed है:
// {
// title: string;
// content: string;
// categoryId: string;
// tags: string[];
// published: boolean;
// }
return ctx.db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
});Dual Validation Trick#
यहां एक subtle बात है: Zod validation दोनों sides पर run होती है। Client पर, tRPC request भेजने से पहले input validate करता है। अगर input invalid है, request browser से बाहर ही नहीं जाती। Server पर, same schema security measure के रूप में फिर से validate करता है।
इसका मतलब free में instant client-side validation मिलती है:
"use client";
import { trpc } from "@/lib/trpc";
import { useState } from "react";
export function CreatePostForm() {
const [title, setTitle] = useState("");
const utils = trpc.useUtils();
const createPost = trpc.post.create.useMutation({
onSuccess: () => {
utils.post.list.invalidate();
},
onError: (error) => {
// Zod errors structured arrive होते हैं
if (error.data?.zodError) {
const fieldErrors = error.data.zodError.fieldErrors;
// fieldErrors.title?: string[]
// fieldErrors.content?: string[]
console.log(fieldErrors);
}
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
createPost.mutate({
title,
content: "...",
categoryId: "...",
});
}}
>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
{createPost.error?.data?.zodError?.fieldErrors.title && (
<span className="text-red-500">
{createPost.error.data.zodError.fieldErrors.title[0]}
</span>
)}
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? "Creating..." : "Create Post"}
</button>
</form>
);
}Complex Input Patterns#
// Discriminated unions
const searchInput = z.discriminatedUnion("type", [
z.object({
type: z.literal("user"),
query: z.string(),
includeInactive: z.boolean().default(false),
}),
z.object({
type: z.literal("post"),
query: z.string(),
category: z.string().optional(),
}),
]);
// Pagination input जो procedures में reuse होता है
const paginationInput = z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).max(100).default(20),
});
const postRouter = router({
infiniteList: publicProcedure
.input(
z.object({
...paginationInput.shape,
category: z.string().optional(),
sortBy: z.enum(["newest", "popular", "trending"]).default("newest"),
})
)
.query(async ({ ctx, input }) => {
const { cursor, limit, category, sortBy } = input;
const posts = await ctx.db.post.findMany({
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
where: category ? { categoryId: category } : undefined,
orderBy:
sortBy === "newest"
? { createdAt: "desc" }
: sortBy === "popular"
? { likes: "desc" }
: { score: "desc" },
});
let nextCursor: string | undefined;
if (posts.length > limit) {
const nextItem = posts.pop();
nextCursor = nextItem?.id;
}
return {
posts,
nextCursor,
};
}),
});Optional Inputs#
हर procedure को input नहीं चाहिए। Queries को अक्सर नहीं चाहिए:
const statsRouter = router({
// कोई input नहीं चाहिए
overview: publicProcedure.query(async ({ ctx }) => {
const [userCount, postCount, commentCount] = await Promise.all([
ctx.db.user.count(),
ctx.db.post.count(),
ctx.db.comment.count(),
]);
return { userCount, postCount, commentCount };
}),
// Optional filters
detailed: publicProcedure
.input(
z
.object({
from: z.date().optional(),
to: z.date().optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
const where = {
...(input?.from && { createdAt: { gte: input.from } }),
...(input?.to && { createdAt: { lte: input.to } }),
};
return ctx.db.post.groupBy({
by: ["categoryId"],
where,
_count: true,
});
}),
});Error Handling#
tRPC का error handling structured, type-safe है, और HTTP semantics और client-side UI दोनों के साथ cleanly integrate होता है।
Server पर Errors Throw करना#
import { TRPCError } from "@trpc/server";
const postRouter = router({
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const post = await ctx.db.post.findUnique({
where: { id: input.id },
});
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
});
}
if (post.authorId !== ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only delete your own posts",
});
}
await ctx.db.post.delete({ where: { id: input.id } });
return { success: true };
}),
});tRPC error codes HTTP status codes पर map होते हैं:
| tRPC Code | HTTP Status | कब इस्तेमाल करें |
|---|---|---|
BAD_REQUEST | 400 | Zod validation से परे invalid input |
UNAUTHORIZED | 401 | Login नहीं है |
FORBIDDEN | 403 | Login है लेकिन insufficient permissions |
NOT_FOUND | 404 | Resource exist नहीं करता |
CONFLICT | 409 | Duplicate resource |
TOO_MANY_REQUESTS | 429 | Rate limit exceeded |
INTERNAL_SERVER_ERROR | 500 | Unexpected server error |
Custom Error Formatting#
Setup में बनाया error formatter याद है? यह practice में कैसे काम करता है:
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
// Custom fields add करो
timestamp: new Date().toISOString(),
requestId: crypto.randomUUID(),
},
};
},
});Client-Side Error Handling#
"use client";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
export function DeletePostButton({ postId }: { postId: string }) {
const utils = trpc.useUtils();
const deletePost = trpc.post.delete.useMutation({
onSuccess: () => {
toast.success("Post deleted");
utils.post.list.invalidate();
},
onError: (error) => {
switch (error.data?.code) {
case "FORBIDDEN":
toast.error("You don't have permission to delete this post");
break;
case "NOT_FOUND":
toast.error("This post no longer exists");
utils.post.list.invalidate();
break;
default:
toast.error("Something went wrong. Please try again.");
}
},
});
return (
<button
onClick={() => deletePost.mutate({ id: postId })}
disabled={deletePost.isPending}
>
{deletePost.isPending ? "Deleting..." : "Delete"}
</button>
);
}Global Error Handling#
एक global error handler set up कर सकते हो जो सभी unhandled tRPC errors catch करे:
// अपने TRPCProvider में
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
// Unhandled mutation errors के लिए global fallback
if (error instanceof TRPCClientError) {
if (error.data?.code === "UNAUTHORIZED") {
// Login पर redirect करो
window.location.href = "/login";
return;
}
toast.error(error.message);
}
},
},
},
})
);Mutations और Optimistic Updates#
Mutations वो जगह है जहां tRPC वाकई TanStack Query के साथ अच्छे से integrate होता है। एक real-world pattern देखते हैं: optimistic updates के साथ like button।
Basic Mutation#
// Server
const postRouter = router({
toggleLike: protectedProcedure
.input(z.object({ postId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.like.findUnique({
where: {
userId_postId: {
userId: ctx.user.id,
postId: input.postId,
},
},
});
if (existing) {
await ctx.db.like.delete({
where: { id: existing.id },
});
return { liked: false };
}
await ctx.db.like.create({
data: {
userId: ctx.user.id,
postId: input.postId,
},
});
return { liked: true };
}),
});Optimistic Updates#
User "Like" click करता है। Server response के लिए 200ms wait नहीं करना चाहते UI update करने के लिए। Optimistic updates यह solve करते हैं: UI तुरंत update करो, फिर server reject करे तो roll back करो।
"use client";
import { trpc } from "@/lib/trpc";
export function LikeButton({ postId, initialLiked, initialCount }: {
postId: string;
initialLiked: boolean;
initialCount: number;
}) {
const utils = trpc.useUtils();
const toggleLike = trpc.post.toggleLike.useMutation({
onMutate: async ({ postId }) => {
// Outgoing refetches cancel करो ताकि वो optimistic update overwrite न करें
await utils.post.byId.cancel({ id: postId });
// Previous value का snapshot लो
const previousPost = utils.post.byId.getData({ id: postId });
// Cache optimistically update करो
utils.post.byId.setData({ id: postId }, (old) => {
if (!old) return old;
return {
...old,
liked: !old.liked,
likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
};
});
// Rollback के लिए snapshot return करो
return { previousPost };
},
onError: (_error, { postId }, context) => {
// Error पर previous value पर roll back करो
if (context?.previousPost) {
utils.post.byId.setData({ id: postId }, context.previousPost);
}
},
onSettled: (_data, _error, { postId }) => {
// Server state ensure करने के लिए हमेशा refetch करो
utils.post.byId.invalidate({ id: postId });
},
});
return (
<button
onClick={() => toggleLike.mutate({ postId })}
className={initialLiked ? "text-red-500" : "text-gray-400"}
>
♥ {initialCount}
</button>
);
}Pattern हमेशा same है:
onMutate: Queries cancel करो, current data snapshot लो, optimistic update apply करो, snapshot return करो।onError: Snapshot इस्तेमाल करके roll back करो।onSettled: Query invalidate करो ताकि server से refetch हो, चाहे success हो या error।
यह three-step dance ensure करता है कि UI हमेशा responsive है और eventually server के साथ consistent है।
Related Queries Invalidate करना#
Mutation के बाद, अक्सर related data refresh करना होता है। tRPC का useUtils() यह ergonomic बनाता है:
const createComment = trpc.comment.create.useMutation({
onSuccess: (_data, variables) => {
// Post की comment list invalidate करो
utils.comment.listByPost.invalidate({ postId: variables.postId });
// Post itself invalidate करो (comment count बदल गया)
utils.post.byId.invalidate({ id: variables.postId });
// सभी post lists invalidate करो (list views में comment counts)
utils.post.list.invalidate();
},
});Batching और Subscriptions#
HTTP Batching#
Default रूप से, httpBatchLink के साथ tRPC multiple simultaneous requests को एक single HTTP call में combine करता है। अगर एक component render होता है और तीन queries fire करता है:
function Dashboard() {
const user = trpc.user.me.useQuery();
const posts = trpc.post.list.useQuery({ limit: 10 });
const stats = trpc.stats.overview.useQuery();
// ...
}ये तीन queries automatically एक single HTTP request में batch होती हैं: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...
Server तीनों process करता है, तीनों results एक single response में return करता है, और TanStack Query results को हर hook तक distribute करता है। कोई configuration ज़रूरी नहीं।
ज़रूरत हो तो specific calls के लिए batching disable कर सकते हो:
// अपने provider में, specific procedures को differently route करने के लिए splitLink इस्तेमाल करो
import { splitLink, httpBatchLink, httpLink } from "@trpc/client";
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
splitLink({
condition: (op) => op.path === "post.infiniteList",
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
maxURLLength: 2048,
}),
}),
],
})
);WebSocket Subscriptions#
Real-time features के लिए, tRPC WebSockets पर subscriptions support करता है। इसके लिए अलग WebSocket server चाहिए (Next.js Route Handlers में natively WebSockets support नहीं करता)।
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
// In-memory event emitter (production में Redis pub/sub इस्तेमाल करें)
import { EventEmitter } from "events";
const eventEmitter = new EventEmitter();
export const notificationRouter = router({
onNew: protectedProcedure.subscription(({ ctx }) => {
return observable<Notification>((emit) => {
const handler = (notification: Notification) => {
if (notification.userId === ctx.user.id) {
emit.next(notification);
}
};
eventEmitter.on("notification", handler);
return () => {
eventEmitter.off("notification", handler);
};
});
}),
// Mutation जो notification trigger करता है
markAsRead: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const notification = await ctx.db.notification.update({
where: { id: input.id, userId: ctx.user.id },
data: { readAt: new Date() },
});
return notification;
}),
});Client पर:
"use client";
import { trpc } from "@/lib/trpc";
import { useState } from "react";
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]);
trpc.notification.onNew.useSubscription(undefined, {
onData: (notification) => {
setNotifications((prev) => [notification, ...prev]);
toast.info(notification.message);
},
onError: (error) => {
console.error("Subscription error:", error);
},
});
return (
<div>
<span className="badge">{notifications.length}</span>
{/* notification list UI */}
</div>
);
}WebSocket transport के लिए, dedicated server process चाहिए। यहां ws library के साथ एक minimal setup है:
// ws-server.ts (अलग process)
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import { appRouter } from "./server/routers/_app";
import { createTRPCContext } from "./server/trpc";
const wss = new WebSocketServer({ port: 3001 });
const handler = applyWSSHandler({
wss,
router: appRouter,
createContext: createTRPCContext,
});
wss.on("connection", (ws) => {
console.log(`Connection opened (${wss.clients.size} total)`);
ws.once("close", () => {
console.log(`Connection closed (${wss.clients.size} total)`);
});
});
process.on("SIGTERM", () => {
handler.broadcastReconnectNotification();
wss.close();
});
console.log("WebSocket server listening on ws://localhost:3001");और client को subscriptions के लिए wsLink चाहिए:
import { wsLink, createWSClient } from "@trpc/client";
const wsClient = createWSClient({
url: "ws://localhost:3001",
});
// Subscriptions को WebSocket से route करने के लिए splitLink इस्तेमाल करो
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
splitLink({
condition: (op) => op.type === "subscription",
true: wsLink({ client: wsClient, transformer: superjson }),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
}),
],
})
);Type-Safe File Uploads#
tRPC file uploads natively handle नहीं करता। यह JSON-RPC protocol है — binary data इसके wheelhouse में नहीं है। लेकिन tRPC को presigned URLs के साथ combine करके type-safe upload flow बना सकते हो।
Pattern:
- Client tRPC से presigned upload URL मांगता है।
- tRPC request validate करता है, permissions check करता है, URL generate करता है।
- Client presigned URL इस्तेमाल करके directly S3 पर upload करता है।
- Client tRPC को notify करता है कि upload complete हो गया।
// src/server/routers/upload.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export const uploadRouter = router({
getPresignedUrl: protectedProcedure
.input(
z.object({
filename: z.string().min(1).max(255),
contentType: z.string().regex(/^(image|application)\//),
size: z.number().max(10 * 1024 * 1024, "File must be under 10MB"),
})
)
.mutation(async ({ ctx, input }) => {
const key = `uploads/${ctx.user.id}/${crypto.randomUUID()}-${input.filename}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: input.contentType,
ContentLength: input.size,
});
const presignedUrl = await getSignedUrl(s3, command, {
expiresIn: 300, // 5 minutes
});
// Database में pending upload store करो
const upload = await ctx.db.upload.create({
data: {
key,
userId: ctx.user.id,
filename: input.filename,
contentType: input.contentType,
size: input.size,
status: "PENDING",
},
});
return {
uploadId: upload.id,
presignedUrl,
key,
};
}),
confirmUpload: protectedProcedure
.input(z.object({ uploadId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const upload = await ctx.db.upload.findUnique({
where: { id: input.uploadId, userId: ctx.user.id },
});
if (!upload) {
throw new TRPCError({ code: "NOT_FOUND" });
}
// Verify करो कि file actually S3 में exist करती है
// (optional लेकिन recommended)
const confirmed = await ctx.db.upload.update({
where: { id: upload.id },
data: { status: "CONFIRMED" },
});
return {
url: `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${confirmed.key}`,
};
}),
});Client पर:
"use client";
import { trpc } from "@/lib/trpc";
import { useState, useCallback } from "react";
export function FileUpload() {
const [uploading, setUploading] = useState(false);
const getPresignedUrl = trpc.upload.getPresignedUrl.useMutation();
const confirmUpload = trpc.upload.confirmUpload.useMutation();
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
// Step 1: tRPC से presigned URL लो
const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type,
size: file.size,
});
// Step 2: Directly S3 पर upload करो
const uploadResponse = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("Upload failed");
}
// Step 3: tRPC से upload confirm करो
const { url } = await confirmUpload.mutateAsync({ uploadId });
toast.success(`File uploaded: ${url}`);
} catch (error) {
toast.error("Upload failed. Please try again.");
} finally {
setUploading(false);
}
},
[getPresignedUrl, confirmUpload]
);
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
accept="image/*"
/>
{uploading && <p>Uploading...</p>}
</div>
);
}पूरा flow type-safe है। Presigned URL response type, upload ID type, confirmation response — सब server definitions से inferred हैं। Presigned URL response में नया field add करो, client को तुरंत पता चलता है।
Server-Side Calls और React Server Components#
Next.js App Router के साथ, अक्सर Server Components में data fetch करना होता है। tRPC server-side callers से यह support करता है:
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
// Server Component में usage
// src/app/posts/[id]/page.tsx
import { createTRPCContext } from "@/server/trpc";
import { createCaller } from "@/server/trpc";
export default async function PostPage({
params,
}: {
params: { id: string };
}) {
const ctx = await createTRPCContext({
req: new Request("http://localhost"),
resHeaders: new Headers(),
});
const caller = createCaller(ctx);
const post = await caller.post.byId({ id: params.id });
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
{/* Interactive parts के लिए client components */}
<LikeButton postId={post.id} initialLiked={post.liked} />
<CommentSection postId={post.id} />
</article>
);
}यह दोनों दुनिया का best देता है: full type safety के साथ server-rendered initial data, और mutations और real-time features के लिए client-side interactivity।
tRPC Procedures Test करना#
Testing straightforward है क्योंकि procedures बस functions हैं। HTTP server spin up करने की ज़रूरत नहीं।
// src/server/routers/user.test.ts
import { describe, it, expect, vi } from "vitest";
import { appRouter } from "./routers/_app";
import { createCaller } from "./trpc";
describe("user router", () => {
it("returns the current user profile", async () => {
const caller = createCaller({
user: { id: "user-1", email: "test@example.com", role: "USER" },
db: prismaMock,
session: mockSession,
headers: {},
});
prismaMock.user.findUnique.mockResolvedValue({
id: "user-1",
name: "Test User",
email: "test@example.com",
image: null,
createdAt: new Date("2026-01-01"),
});
const result = await caller.user.me();
expect(result).toEqual({
id: "user-1",
name: "Test User",
email: "test@example.com",
image: null,
createdAt: new Date("2026-01-01"),
});
});
it("throws UNAUTHORIZED for unauthenticated requests", async () => {
const caller = createCaller({
user: null,
db: prismaMock,
session: null,
headers: {},
});
await expect(caller.user.me()).rejects.toThrow("UNAUTHORIZED");
});
it("validates input with Zod", async () => {
const caller = createCaller({
user: { id: "user-1", email: "test@example.com", role: "USER" },
db: prismaMock,
session: mockSession,
headers: {},
});
await expect(
caller.user.updateProfile({
name: "", // min length 1
})
).rejects.toThrow();
});
});HTTP layers mock करने की ज़रूरत नहीं, supertest नहीं, route matching नहीं। बस function call करो और result assert करो। यह tRPC के underappreciated advantages में से एक है: testing trivially simple है क्योंकि transport layer एक implementation detail है।
कब tRPC इस्तेमाल न करें#
tRPC universal solution नहीं है। यहां वो जगहें हैं जहां यह काम नहीं करता:
Public APIs#
अगर ऐसी API build कर रहे हो जो external developers consume करेंगे, tRPC गलत choice है। External consumers के पास आपके TypeScript types तक access नहीं है। उन्हें documented, stable contract चाहिए — REST के लिए OpenAPI/Swagger, या GraphQL schema। tRPC की type safety सिर्फ तब काम करती है जब client और server दोनों same TypeScript codebase share करें।
Mobile Apps (जब तक TypeScript इस्तेमाल न करें)#
अगर mobile app Swift, Kotlin, या Dart में लिखी है, tRPC कुछ offer नहीं करता। Types language boundaries cross नहीं करते। Theoretically trpc-openapi इस्तेमाल करके tRPC routes से OpenAPI spec generate कर सकते हो, लेकिन उस point पर ceremony वापस add हो रही है। शुरू से REST इस्तेमाल करो।
Microservices#
tRPC single TypeScript codebase assume करता है। अगर backend multiple services में split है different languages में, tRPC inter-service communication में मदद नहीं कर सकता। उसके लिए gRPC, REST, या message queues इस्तेमाल करो।
बड़ी Teams जिनके Separate Frontend/Backend Repos हैं#
अगर frontend और backend अलग-अलग repositories में रहते हैं अलग deploy pipelines के साथ, tRPC का core advantage खो जाता है। Type sharing monorepo या shared package चाहता है। AppRouter type npm package के रूप में publish कर सकते हो, लेकिन अब versioning problem है जो REST + OpenAPI ज़्यादा naturally handle करता है।
जब REST Semantics चाहिए#
अगर HTTP caching headers, content negotiation, ETags, या other REST-specific features चाहिए, tRPC का HTTP पर abstraction आपसे लड़ेगा। tRPC HTTP को transport detail treat करता है, feature नहीं।
Decision Framework#
यहां मैं कैसे decide करता हूं:
| Scenario | Recommendation |
|---|---|
| Same-repo fullstack TypeScript app | tRPC — maximum benefit, minimum overhead |
| Internal tool / admin dashboard | tRPC — development speed priority है |
| Third-party devs के लिए public API | REST + OpenAPI — consumers को docs चाहिए, types नहीं |
| Mobile + web clients (non-TS mobile) | REST या GraphQL — language-agnostic contracts चाहिए |
| Real-time heavy (chat, gaming) | tRPC subscriptions या raw WebSockets complexity पर depend करता है |
| Separate frontend/backend teams | GraphQL — schema teams के बीच contract है |
Production से Practical Tips#
कुछ चीज़ें जो मैंने production में tRPC चलाते हुए सीखीं जो docs में नहीं हैं:
Routers छोटे रखो। एक single router file 200 lines से ज़्यादा नहीं होनी चाहिए। Domain से split करो: userRouter, postRouter, billingRouter। हर एक अपनी file में।
Server-side calls के लिए createCallerFactory इस्तेमाल करो। Server Component से अपनी ही API call करते समय fetch मत पकड़ो। Caller factory same type safety देती है zero HTTP overhead के साथ।
Batching को over-optimize मत करो। Default httpBatchLink almost हमेशा sufficient है। मैंने teams को marginal gains के लिए splitLink configurations set up करते दिनों बिताते देखा है। पहले profile करो।
QueryClient में staleTime set करो। Default staleTime 0 का मतलब है हर focus event refetch trigger करता है। अपने data freshness requirements के हिसाब से reasonable कुछ set करो (30 seconds से 5 minutes)।
Day one से superjson इस्तेमाल करो। बाद में add करने का मतलब हर client और server simultaneously migrate करना है। यह one-line configuration है जो Date serialization bugs से बचाती है।
Error boundaries आपके दोस्त हैं। tRPC-heavy page sections को React error boundaries में wrap करो। एक failed query पूरा page down नहीं करनी चाहिए।
"use client";
import { ErrorBoundary } from "react-error-boundary";
function ErrorFallback({ error, resetErrorBoundary }: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<div role="alert" className="p-4 bg-red-50 rounded-lg">
<p className="font-medium text-red-800">Something went wrong</p>
<pre className="text-sm text-red-600 mt-2">{error.message}</pre>
<button
onClick={resetErrorBoundary}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded"
>
Try again
</button>
</div>
);
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<UserStats />
</ErrorBoundary>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<RecentPosts />
</ErrorBoundary>
</div>
);
}Wrapping Up#
tRPC REST या GraphQL का replacement नहीं है। यह एक specific situation के लिए अलग tool है: जब client और server दोनों आपके control में हैं, दोनों TypeScript हैं, और "backend बदला" से "frontend को पता चला" तक सबसे छोटा possible path चाहिए।
उस situation में, कुछ और करीब नहीं आता। कोई code generation नहीं, कोई schema files नहीं, कोई drift नहीं। बस TypeScript वो कर रहा है जो TypeScript सबसे अच्छा करता है: production तक पहुंचने से पहले गलतियां पकड़ना।
Trade-off clear है: protocol-level interoperability (non-TypeScript clients नहीं) छोड़ते हो development speed और compile-time safety के बदले जो किसी और तरीके से achieve करना मुश्किल है।
ज़्यादातर fullstack TypeScript applications के लिए, यह trade-off worth making है।