tRPC Router Bloat — 14-Second Compilation in Next.js 16
A tRPC router with 180 procedures caused 14-second compilation and 3-5 sec autocomplete lag.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- tRPC provides end-to-end type safety — define procedures on the server, call them on the client with full autocomplete
- v11 integrates with Next.js App Router via a single API route handler (/api/trpc/[trpc]/route.ts) for client hooks — Server Components bypass it with createCaller
- Server Components call procedures directly via caller — no HTTP round-trip, no serialization
- React Query (TanStack Query) powers the client cache — stale-while-revalidate, background refetch, optimistic updates
- Input validation uses Zod schemas — the same schema drives types, validation, and OpenAPI docs
- Biggest mistake: creating a monolithic router — split by domain (userRouter, postRouter) for compilation speed and maintainability
tRPC is a TypeScript-native RPC framework that lets you build fully typesafe APIs without code generation or schema definitions. Instead of defining REST endpoints or GraphQL schemas, you write backend procedures (queries and mutations) as regular TypeScript functions, and the client imports them directly—getting full type inference from server to client with zero boilerplate.
The core idea is that your API is just a set of functions, and the framework handles serialization, transport, and error handling automatically. This eliminates the traditional disconnect between frontend and backend types, catching mismatches at compile time rather than runtime.
In the Next.js ecosystem, tRPC replaces the need for a separate API route layer or external client like React Query for data fetching. You define procedures in a router, mount them under a single Next.js API route handler (or use server components for direct calls), and consume them with React hooks that mirror TanStack Query's API.
The tradeoff is that tRPC couples your frontend and backend tightly—both must be in the same monorepo and share TypeScript types. It's ideal for full-stack TypeScript apps where developer velocity and type safety matter more than decoupling, but it's a poor fit for public APIs, microservices, or polyglot backends.
Where tRPC really shines is eliminating the 'type drift' problem that plagues REST and GraphQL setups. With REST, you manually maintain types or use OpenAPI generators; with GraphQL, you need codegen pipelines. tRPC infers types from your actual implementation, so when you change a procedure's input schema or return type, the client instantly knows.
This comes at a cost: the compilation time you're seeing in Next.js 16 is a direct result of TypeScript's type inference engine resolving deeply nested generic types across the entire router tree. For large routers (50+ procedures), this can balloon to 10-15 seconds of compile time, which is the exact problem this article addresses.
tRPC is like a contract between your frontend and backend. You define functions (procedures) on the server with input and output types. On the client, you call those functions as if they were local — you get autocomplete, type checking, and compile-time errors if the types do not match. No manual API type definitions, no code generation step, no schema files to sync. The types flow automatically from the server to the client through TypeScript inference.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
tRPC eliminates the type boundary between frontend and backend. Instead of defining API types manually or running code generation, tRPC infers types directly from your server procedures. The client calls procedures by name with full autocomplete, and TypeScript catches mismatches at compile time — not at runtime in production.
v11 introduces significant changes for Next.js App Router: server-side callers for direct procedure invocation in Server Components, tighter React Query integration for client-side caching, and simplified error handling with the TRPCError class. The App Router integration differs fundamentally from the Pages Router approach — Server Components call procedures directly without HTTP, while Client Components use the React Query-powered hooks.
The production challenges are router organization (monolithic routers become unmaintainable), error handling (tRPC errors must map to HTTP status codes), middleware composition (auth, rate limiting, logging), and the Server Component boundary (procedures called in Server Components bypass the client cache). This guide covers the complete setup with patterns for each concern.
Why tRPC Setup in Next.js 16 Compiles for 14 Seconds
tRPC is a TypeScript-native RPC framework that lets you define API endpoints as typed functions on the server and call them directly from the client with full type safety — no code generation, no schema duplication. The core mechanic is a router object that aggregates all procedures (queries, mutations, subscriptions) and exports a single type for the client to consume. In Next.js 16, this router is typically defined in a shared package or within the app directory, then imported by both the API handler and the client provider.
The key property that matters in practice is that tRPC's type inference is recursive and deep. Every procedure, its input schema, and its output type are resolved at compile time. With 50+ procedures, each using Zod or similar validators, the TypeScript compiler can spend 10–14 seconds just resolving the router type. This is not a runtime cost — it's a build-time tax that compounds with every additional procedure, middleware, or context type.
Use tRPC when you want end-to-end type safety without a separate API layer or codegen step. It shines in monorepos where the same types drive both frontend and backend. But if your router exceeds 30 procedures or uses complex generics, the compilation time becomes a real bottleneck — especially in Next.js 16's Turbopack, which recompiles on every file change. Teams often hit this at 50+ procedures, where a clean build jumps from 3 seconds to 14+.
Project Setup: tRPC v11 with Next.js 16 App Router
tRPC v11 uses a split package structure: @trpc/server for the server-side router and procedures, @trpc/client for the client-side proxy, @trpc/tanstack-react-query for React hooks powered by TanStack Query, and @tanstack/react-query for the underlying cache. The setup requires four files: the root router, the tRPC context, the client provider, and the API route handler.
The App Router integration differs from Pages Router in one critical way: Server Components can call procedures directly via a server-side caller, bypassing HTTP entirely. This means no network round-trip, no serialization overhead, and no React Query cache involvement. Client Components use the React Query hooks (useQuery, useMutation) which go through the API route and benefit from caching.
// ============================================ // tRPC v11 + Next.js 16 — Project Setup // ============================================ // ---- Step 1: Install dependencies ---- // npm install @trpc/server @trpc/client @trpc/tanstack-react-query @tanstack/react-query superjson zod // ---- Step 2: Create the tRPC context ---- // File: server/trpc.ts // This file exports the base tRPC instance with context import { initTRPC, TRPCError } from '@trpc/server' import superjson from 'superjson' import { createClient } from '@/lib/supabase/server' // Define the context type — available in all procedures type TRPCContext = { supabase: Awaited<ReturnType<typeof createClient>> userId: string | null } // Initialize tRPC with the context type const t = initTRPC.context<TRPCContext>().create({ transformer: superjson, // Handles Date, Map, Set, BigInt serialization }) // Export reusable building blocks export const createTRPCRouter = t.router export const publicProcedure = t.procedure export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { if (!ctx.userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You must be logged in to access this resource', }) } return next({ ctx: { ...ctx, userId: ctx.userId, // Narrowed to string — no longer nullable }, }) }) // ---- Step 3: Create the API route handler ---- // File: app/api/trpc/[trpc]/route.ts import { fetchRequestHandler } from '@trpc/server/adapters/fetch' import { appRouter } from '@/server/root' import { createClient } from '@/lib/supabase/server' const handler = (req: Request) => fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext: async () => { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() return { supabase, userId: user?.id ?? null, } }, }) export { handler as GET, handler as POST } // ---- Step 4: Create the root router ---- // File: server/root.ts import { createTRPCRouter } from './trpc' import { userRouter } from './routers/user' import { postRouter } from './routers/post' import { commentRouter } from './routers/comment' import { billingRouter } from './routers/billing' export const appRouter = createTRPCRouter({ user: userRouter, post: postRouter, comment: commentRouter, billing: billingRouter, }) // Export the router type — this is what the client uses for type inference export type AppRouter = typeof appRouter // ---- Step 5: Create the client ---- // File: lib/trpc/client.ts import { createTRPCReact } from '@trpc/tanstack-react-query' import type { AppRouter } from '@/server/root' export const trpc = createTRPCReact<AppRouter>() // ---- Step 6: Create the provider ---- // File: lib/trpc/provider.tsx 'use client' import { useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { trpc } from './client' import { httpBatchLink } from '@trpc/client' import superjson from 'superjson' export function TRPCProvider({ children }: { children: React.ReactNode }) { const [queryClient] = useState(() => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1 minute — data is fresh for 1 minute refetchOnWindowFocus: false, // Disable for SPA-like behavior }, }, })) const [trpcClient] = useState(() => trpc.createClient({ links: [ httpBatchLink({ url: '/api/trpc', transformer: superjson, }), ], }) ) return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> </trpc.Provider> ) } // ---- Step 7: Wrap the layout ---- // File: app/layout.tsx import { TRPCProvider } from '@/lib/trpc/provider' export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <TRPCProvider> {children} </TRPCProvider> </body> </html> ) }
- Server: defines procedures (query/mutation) with input/output schemas via Zod
- Router: groups procedures by domain — merged into a root router for the full API surface
- Client: imports the AppRouter type — gets full autocomplete and compile-time type checking
- API Route: handles HTTP transport — fetchRequestHandler for App Router
- Server Components: call procedures directly via createCaller — no HTTP, no serialization
Procedures: Queries, Mutations, and Input Validation
Procedures are the API surface — each procedure is a function with an input schema, output schema, and resolver. Queries are for read operations (GET semantics), mutations are for write operations (POST semantics). The distinction matters for React Query: queries are cached and refetched automatically, mutations trigger cache invalidation.
Input validation uses Zod schemas. The schema serves three purposes: runtime validation (reject invalid input), type inference (TypeScript knows the input type), and documentation (the schema can generate OpenAPI specs). Output schemas are optional but recommended — they validate the resolver's return value and provide type safety for the client.
// ============================================ // tRPC Procedures — Queries, Mutations, Validation // ============================================ import { z } from 'zod' import { TRPCError } from '@trpc/server' import { createTRPCRouter, publicProcedure, protectedProcedure } from '../trpc' // ---- User Router ---- // File: server/routers/user.ts export const userRouter = createTRPCRouter({ // ---- Query: Get current user profile ---- // Read operation — cached by React Query me: protectedProcedure .query(async ({ ctx }) => { const { data: profile, error } = await ctx.supabase .from('profiles') .select('*') .eq('id', ctx.userId) .single() if (error) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found', }) } return profile }), // ---- Query: List users with pagination ---- // Input validated by Zod — invalid input throws before the resolver runs list: protectedProcedure .input(z.object({ page: z.number().int().min(1).default(1), limit: z.number().int().min(1).max(100).default(20), search: z.string().optional(), sortBy: z.enum(['name', 'email', 'created_at']).default('created_at'), sortOrder: z.enum(['asc', 'desc']).default('desc'), })) .output(z.object({ users: z.array(z.object({ id: z.string(), name: z.string(), email: z.string(), createdAt: z.date(), })), total: z.number(), hasMore: z.boolean(), })) .query(async ({ ctx, input }) => { const offset = (input.page - 1) * input.limit let query = ctx.supabase .from('profiles') .select('*', { count: 'exact' }) .order(input.sortBy, { ascending: input.sortOrder === 'asc' }) .range(offset, offset + input.limit - 1) if (input.search) { query = query.ilike('name', `%${input.search}%`) } const { data: users, count, error } = await query if (error) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to fetch users', }) } return { users: users ?? [], total: count ?? 0, hasMore: (count ?? 0) > offset + input.limit, } }), // ---- Mutation: Update user profile ---- // Write operation — triggers cache invalidation updateProfile: protectedProcedure .input(z.object({ name: z.string().min(2).max(50).optional(), bio: z.string().max(500).optional(), website: z.string().url().optional().or(z.literal('')), })) .mutation(async ({ ctx, input }) => { // Filter out undefined values — only update provided fields const updates = Object.fromEntries( Object.entries(input).filter(([_, v]) => v !== undefined) ) if (Object.keys(updates).length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No fields to update', }) } const { data, error } = await ctx.supabase .from('profiles') .update(updates) .eq('id', ctx.userId) .select() .single() if (error) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to update profile', }) } return data }), // ---- Query: Get user by ID (public) ---- getById: publicProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) => { const { data, error } = await ctx.supabase .from('profiles') .select('id, name, bio, website, created_at') .eq('id', input.id) .single() if (error) { throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found', }) } return data }), }) // ---- Post Router ---- // File: server/routers/post.ts export const postRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ limit: z.number().int().min(1).max(50).default(10), cursor: z.string().uuid().optional(), authorId: z.string().uuid().optional(), })) .query(async ({ ctx, input }) => { let query = ctx.supabase .from('posts') .select('*, profiles(name)') .order('created_at', { ascending: false }) .limit(input.limit + 1) if (input.cursor) { query = query.lt('id', input.cursor) } if (input.authorId) { query = query.eq('author_id', input.authorId) } const { data, error } = await query if (error) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to fetch posts', }) } const posts = data ?? [] const hasMore = posts.length > input.limit const items = hasMore ? posts.slice(0, -1) : posts return { items, nextCursor: hasMore ? items[items.length - 1].id : null, } }), create: protectedProcedure .input(z.object({ title: z.string().min(1).max(200), content: z.string().min(1).max(10000), published: z.boolean().default(false), })) .mutation(async ({ ctx, input }) => { const { data, error } = await ctx.supabase .from('posts') .insert({ title: input.title, content: input.content, published: input.published, author_id: ctx.userId, }) .select() .single() if (error) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create post', }) } return data }), })
- Queries are for reads — cached by React Query, refetched on stale, idempotent
- Mutations are for writes — not cached, trigger invalidation of related queries
- Using a query for a write operation breaks the cache model — data becomes stale
- Using a mutation for a read loses caching benefits — every call hits the server
- The distinction affects React Query behavior — not just HTTP semantics
Server Components: Direct Procedure Calls Without HTTP
Server Components can call tRPC procedures directly via a server-side caller, bypassing HTTP entirely. This means no network round-trip, no serialization overhead, and no React Query cache involvement. The caller runs the procedure's resolver directly with the same context and middleware as an HTTP request.
The key difference from client-side calls: server callers do not use React Query hooks. There is no useQuery, no caching, no background refetch. The data is fetched during server-side rendering and passed to the component as props. This is ideal for initial page loads where the data must be available before the HTML is sent to the client.
// ============================================ // tRPC Server Components — Direct Procedure Calls // ============================================ // ---- Create the server caller ---- // File: lib/trpc/server-caller.ts // This creates a caller that runs procedures directly — no HTTP import { appRouter } from '@/server/root' import { createClient } from '@/lib/supabase/server' export async function createServerCaller() { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() // createCaller runs procedures with the same context as HTTP requests // But it bypasses the HTTP layer entirely return appRouter.createCaller({ supabase, userId: user?.id ?? null, }) } // ---- Server Component: Fetch user profile ---- // File: app/dashboard/page.tsx import { createServerCaller } from '@/lib/trpc/server-caller' import { redirect } from 'next/navigation' export default async function DashboardPage() { const caller = await createServerCaller() try { // Direct procedure call — no HTTP, no serialization // Full type safety — autocomplete works on caller.user.me const profile = await caller.user.me() return ( <div> <h1>Welcome, {profile.name}</h1> <p>{profile.bio}</p> </div> ) } catch (error) { // tRPCError from the protectedProcedure middleware redirect('/login') } } // ---- Server Component: Fetch paginated posts ---- // File: app/posts/page.tsx import { createServerCaller } from '@/lib/trpc/server-caller' import { PostList } from '@/components/post-list' export default async function PostsPage({ searchParams, }: { searchParams: Promise<{ page?: string }> }) { const params = await searchParams const page = parseInt(params.page ?? '1', 10) const caller = await createServerCaller() // Server-side fetch — data is available before HTML is sent const { items: posts, nextCursor } = await caller.post.list({ limit: 10, }) return ( <div> <h1>Posts</h1> {/* Pass server-fetched data to a Client Component */} <PostList initialPosts={posts} initialCursor={nextCursor} /> </div> ) } // ---- Client Component: Infinite scroll with initial data ---- // File: components/post-list.tsx 'use client' import { useState } from 'react' import { trpc } from '@/lib/trpc/client' export function PostList({ initialPosts, initialCursor, }: { initialPosts: Post[] initialCursor: string | null }) { const [cursor, setCursor] = useState(initialCursor) // useInfiniteQuery with initialData from the server // The first page is already loaded — no flash of loading state const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = trpc.post.list.useInfiniteQuery( { limit: 10 }, { getNextPageParam: (lastPage) => lastPage.nextCursor, initialData: { pages: [{ items: initialPosts, nextCursor: initialCursor }], pageParams: [undefined], }, } ) const allPosts = data?.pages.flatMap((page) => page.items) ?? [] return ( <div> {allPosts.map((post) => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.content.slice(0, 200)}...</p> </article> ))} {hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage} > {isFetchingNextPage ? 'Loading...' : 'Load More'} </button> )} </div> ) } // ---- Server Component + Client Component pattern ---- // The server fetches initial data, the client handles interactivity // This eliminates the loading state flash on initial page load // File: app/users/[id]/page.tsx import { createServerCaller } from '@/lib/trpc/server-caller' import { UserProfile } from '@/components/user-profile' import { notFound } from 'next/navigation' export default async function UserPage({ params, }: { params: Promise<{ id: string }> }) { const { id } = await params const caller = await createServerCaller() try { const user = await caller.user.getById({ id }) return ( <UserProfile initialUser={user} userId={id} /> ) } catch { notFound() } }
- Server caller: runs the procedure resolver directly — no network, no serialization, no cache
- Client hooks: sends HTTP request to /api/trpc — cached by React Query, refetched on stale
- Server caller is for SSR — data is available before HTML is sent to the client
- Client hooks are for interactivity — mutations, real-time updates, infinite scroll
- Pass server-fetched data as initialData to client hooks — eliminates loading state flash
Client-Side Hooks: useQuery, useMutation, and Cache Management
Client-side hooks are powered by React Query (TanStack Query). useQuery fetches and caches data, use queries, and useInfiniteQuery handles cursor-based pagination. The cache is the key performance feature — data is served from cache instantly while a background refetch checks for updates.
Cache management is critical: after a mutation, related queries must be invalidated to show fresh data. tRPC provides type-safe invalidation helpers that match the query key automatically. Without invalidation, the UI shows stale data after writesMutation triggers writes and invalidates related.
// ============================================ // tRPC Client Hooks — useQuery, useMutation, Cache // ============================================ 'use client' import { trpc } from '@/lib/trpc/client' import { useState } from 'react' // ---- useQuery: Fetch and cache data ---- // React Query handles: caching, background refetch, stale-while-revalidate export function UserProfile({ userId }: { userId: string }) { const { data: user, isLoading, error, refetch } = trpc.user.getById.useQuery( { id: userId }, { enabled: !!userId, // Only fetch when userId is available staleTime: 5 * 60 * 1000, // Data is fresh for 5 minutes retry: 2, // Retry twice on failure } ) if (isLoading) return <div>Loading...</div> if (error) return <div>Error: {error.message}</div> if (!user) return <div>User not found</div> return ( <div> <h1>{user.name}</h1> <p>{user.bio}</p> <button onClick={() => refetch()}>Refresh</button> </div> ) } // ---- useMutation: Write data and invalidate cache ---- export function EditProfileForm() { const utils = trpc.useUtils() const updateProfile = trpc.user.updateProfile.useMutation({ onSuccess: () => { // Invalidate the 'me' query — triggers a refetch utils.user.me.invalidate() // Optionally update the cache directly for instant UI update // utils.user.me.setData(undefined, updatedProfile) }, onError: (error) => { console.error('Update failed:', error.message) }, }) const [name, setName] = useState('') const [bio, setBio] = useState('') async function handleSubmit(e: React.FormEvent) { e.preventDefault() updateProfile.mutate({ name, bio }) } return ( <form onSubmit={handleSubmit}> <input value={name} onChange={(e) => setName(e.target.value)} /> <textarea value={bio} onChange={(e) => setBio(e.target.value)} /> <button type="submit" disabled={updateProfile.isPending}> {updateProfile.isPending ? 'Saving...' : 'Save'} </button> {updateProfile.isError && ( <p className="text-destructive">{updateProfile.error.message}</p> )} {updateProfile.isSuccess && <p className="text-green-600">Saved!</p>} </form> ) } // ---- useMutation with optimistic updates ---- // Update the UI before the server responds — feels instant export function LikeButton({ postId }: { postId: string }) { const utils = trpc.useUtils() const likePost = trpc.post.like.useMutation({ onMutate: async ({ postId }) => { // Cancel outgoing refetches — prevent overwriting the optimistic update await utils.post.list.cancel() // Snapshot the previous value const previousPosts = utils.post.list.getInfiniteData() // Optimistically update the cache utils.post.list.setInfiniteData({ limit: 10 }, (old) => { if (!old) return old return { ...old, pages: old.pages.map((page) => ({ ...page, items: page.items.map((post) => post.id === postId ? { ...post, likes: post.likes + 1, liked: true } : post ), })), } }) return { previousPosts } }, onError: (_err, _variables, context) => { // Rollback on error — restore the previous cache state if (context?.previousPosts) { utils.post.list.setInfiniteData({ limit: 10 }, context.previousPosts) } }, onSettled: () => { // Refetch to ensure consistency — regardless of success or failure utils.post.list.invalidate() }, }) return ( <button onClick={() => likePost.mutate({ postId })} disabled={likePost.isPending} > Like </button> ) } // ---- Prefetching: Load data before the user navigates ---- export function PostLink({ postId }: { postId: string }) { const utils = trpc.useUtils() return ( <a href={`/posts/${postId}`} onMouseEnter={() => { // Prefetch on hover — data is ready when the user clicks utils.post.getById.prefetch({ id: postId }) }} > View Post </a> ) } // ---- Type-safe query key access ---- // tRPC provides type-safe helpers for cache management export function CacheManagement() { const utils = trpc.useUtils() function handleRefreshAll() { // Invalidate all user queries utils.user.invalidate() } function handleRefreshMe() { // Invalidate only the 'me' query utils.user.me.invalidate() } function handleResetCache() { // Reset the entire cache utils.invalidate() } return ( <div> <button onClick={handleRefreshAll}>Refresh All Users</button> <button onClick={handleRefreshMe}>Refresh My Profile</button> <button onClick={handleResetCache}>Reset All Cache</button> </div> ) }
- Always invalidate related queries after a mutation — stale data causes user confusion
- Optimistic updates make the UI feel instant — update the cache before the server responds
- Rollback on error — restore the previous cache state if the mutation fails
- Prefetch on hover — data is ready when the user clicks, eliminating loading states
- useUtils() provides type-safe cache access — invalidate, setData, prefetch, cancel
Error Handling: TRPCError, Error Formatters, and Client-Side Handling
tRPC uses TRPCError for structured error handling. Each error has a code (UNAUTHORIZED, NOT_FOUND, BAD_REQUEST, INTERNAL_SERVER_ERROR, etc.) and a message. The error formatter on the server controls what information is sent to the client — never expose stack traces or internal details in production.
The client receives errors through the hook's error property. For mutations, the error is available on mutation.error. For queries, the error is available on query.error. Both provide the error code and message for conditional rendering.
// ============================================ // tRPC Error Handling — Server and Client // ============================================ // ---- Server: Custom error formatter ---- // File: server/trpc.ts (add to initTRPC) import { initTRPC, TRPCError } from '@trpc/server' import { ZodError } from 'zod' const t = initTRPC.context<TRPCContext>().create({ transformer: superjson, errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, // Include Zod validation errors as field-level errors zodError: error.code === 'BAD_REQUEST' && error.cause instanceof ZodError ? error.cause.flatten().fieldErrors : null, // Include the error code for client-side handling code: error.code, }, } }, }) // ---- Server: Throwing structured errors in procedures ---- import { TRPCError } from '@trpc/server' // Unauthorized — user is not logged in throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You must be logged in', }) // Not found — resource does not exist throw new TRPCError({ code: 'NOT_FOUND', message: 'Post not found', }) // Bad request — invalid input (usually caught by Zod automatically) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Page number must be positive', }) // Forbidden — user is logged in but lacks permission throw new TRPCError({ code: 'FORBIDDEN', message: 'You do not have permission to delete this post', }) // Internal server error — unexpected failure throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to process payment', cause: error, // Original error — logged on server, not sent to client }) // Rate limited throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'Rate limit exceeded. Please try again in 60 seconds.', }) // ---- Server: Error logging middleware ---- // Log all errors for observability import { middleware } from '../trpc' const errorLogger = middleware(async ({ path, type, next, ctx }) => { const start = Date.now() try { const result = await next() const duration = Date.now() - start // Log successful calls in development if (process.env.NODE_ENV === 'development') { console.log(`[tRPC] ${type} ${path} completed in ${duration}ms`) } return result } catch (error) { const duration = Date.now() - start // Log all errors console.error(`[tRPC] ${type} ${path} failed in ${duration}ms`, { error: error instanceof Error ? error.message : 'Unknown error', code: error instanceof TRPCError ? error.code : 'UNKNOWN', userId: ctx.userId, path, type, }) // Re-throw — tRPC handles the response throw error } }) // ---- Client: Error handling in hooks ---- 'use client' import { trpc } from '@/lib/trpc/client' export function PostEditor({ postId }: { postId: string }) { const utils = trpc.useUtils() const updatePost = trpc.post.update.useMutation({ onSuccess: () => { utils.post.getById.invalidate({ id: postId }) utils.post.list.invalidate() }, }) function handleSave(title: string, content: string) { updatePost.mutate({ id: postId, title, content }) } return ( <div> {/* Error handling by error code */} {updatePost.error && ( <div className="rounded bg-destructive/10 p-4 text-destructive"> {updatePost.error.data?.code === 'UNAUTHORIZED' && ( <p>Your session has expired. Please log in again.</p> )} {updatePost.error.data?.code === 'NOT_FOUND' && ( <p>This post no longer exists.</p> )} {updatePost.error.data?.code === 'FORBIDDEN' && ( <p>You do not have permission to edit this post.</p> )} {updatePost.error.data?.code === 'BAD_REQUEST' && ( <div> <p>Please fix the following errors:</p> {updatePost.error.data?.zodError && ( <ul> {Object.entries(updatePost.error.data.zodError).map( ([field, errors]) => ( <li key={field}> {field}: {(errors as string[]).join(', ')} </li> ) )} </ul> )} </div> )} {!['UNAUTHORIZED', 'NOT_FOUND', 'FORBIDDEN', 'BAD_REQUEST'].includes( updatePost.error.data?.code ?? '' ) && ( <p>An unexpected error occurred. Please try again.</p> )} </div> )} <button onClick={() => handleSave('Title', 'Content')}> Save </button> </div> ) } // ---- Client: Error boundary for queries ---- export function PostPage({ postId }: { postId: string }) { const { data: post, isLoading, error, refetch } = trpc.post.getById.useQuery( { id: postId } ) if (isLoading) return <div>Loading...</div> if (error) { if (error.data?.code === 'NOT_FOUND') { return <div>Post not found</div> } return ( <div> <p>Failed to load post: {error.message}</p> <button onClick={() => refetch()}>Retry</button> </div> ) } if (!post) return <div>Post not found</div> return ( <article> <h1>{post.title}</h1> <p>{post.content}</p> </article> ) }
- Never expose stack traces or internal error details to the client — use the error formatter
- Always include an error code — clients use it for conditional rendering, not error.message
- Zod validation errors should be returned as field-level errors — not a single message string
- Log all errors server-side with context (userId, path, type, duration) — for observability
- Error cause is logged on the server but never sent to the client — prevents information leakage
Middleware: Auth, Rate Limiting, and Logging
Middleware in tRPC wraps procedures with cross-cutting concerns: authentication checks, rate limiting, input transformation, logging, and authorization. Middleware runs before the procedure resolver and can modify the context, reject the request, or wrap the result.
The middleware chain is composable — you can stack multiple middleware on a single procedure. The order matters: middleware runs in the order it is applied, and each middleware calls next() to proceed to the next middleware or the resolver.
// ============================================ // tRPC Middleware — Auth, Rate Limiting, Logging // ============================================ import { middleware, protectedProcedure } from '../trpc' import { TRPCError } from '@trpc/server' // ---- Middleware: Rate limiting (Upstash Redis recommended for production) ---- // In-memory version shown for illustration — does NOT work across serverless instances const rateLimitMap = new Map<string, { count: number; resetTime: number }>() export const rateLimit = middleware(async ({ ctx, path, next }) => { const key = `${ctx.userId}:${path}` const now = Date.now() const windowMs = 60_000 // 1 minute const maxRequests = 100 let record = rateLimitMap.get(key) if (!record || now > record.resetTime) { record = { count: 0, resetTime: now + windowMs } rateLimitMap.set(key, record) } if (record.count >= maxRequests) { throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'Rate limit exceeded. Please try again in 60 seconds.', }) } record.count++ return next() }) // ---- Middleware: Role-based authorization ---- export function requireRole(role: 'admin' | 'moderator') { return middleware(async ({ ctx, next }) => { const { data: profile } = await ctx.supabase .from('profiles') .select('role') .eq('id', ctx.userId) .single() if (!profile || (profile.role !== role && profile.role !== 'admin')) { throw new TRPCError({ code: 'FORBIDDEN', message: `This action requires ${role} role`, }) } return next({ ctx: { ...ctx, role: profile.role, }, }) }) } export const adminProcedure = protectedProcedure.use(requireRole('admin')) export const moderatorProcedure = protectedProcedure.use(requireRole('moderator')) // ---- Middleware: Request logging ---- export const loggedProcedure = protectedProcedure.use( middleware(async ({ path, type, next, ctx }) => { const start = Date.now() const requestId = crypto.randomUUID() console.log(JSON.stringify({ event: 'tRPC.request.start', requestId, path, type, userId: ctx.userId, timestamp: new Date().toISOString(), })) try { const result = await next() const duration = Date.now() - start console.log(JSON.stringify({ event: 'tRPC.request.success', requestId, path, type, duration, userId: ctx.userId, })) return result } catch (error) { const duration = Date.now() - start console.error(JSON.stringify({ event: 'tRPC.request.error', requestId, path, type, duration, userId: ctx.userId, error: error instanceof Error ? error.message : 'Unknown', code: error instanceof TRPCError ? error.code : 'UNKNOWN', })) throw error } }) ) // ---- Composing middleware: Stack multiple concerns ---- export const adminActionProcedure = protectedProcedure .use(requireRole('admin')) .use(rateLimit) .use(loggedProcedure) // ---- Usage in router ---- // File: server/routers/admin.ts import { createTRPCRouter } from '../trpc' import { adminActionProcedure } from '../middleware' export const adminRouter = createTRPCRouter({ // Simple admin-only query stats: adminProcedure .query(async ({ ctx }) => { const { count } = await ctx.supabase .from('users') .select('*', { count: 'exact', head: true }) return { totalUsers: count ?? 0 } }), // Rate-limited, logged admin mutation deleteUser: adminActionProcedure .input(z.object({ userId: z.string().uuid() })) .mutation(async ({ ctx, input }) => { if (input.userId === ctx.userId) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot delete your own account', }) } const { error } = await ctx.supabase .from('profiles') .delete() .eq('id', input.userId) if (error) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to delete user', }) } return { success: true } }), })
- Create procedure variants by composing middleware: publicProcedure, protectedProcedure, adminProcedure
- Each middleware adds one concern — auth, rate limiting, logging, sanitization
- Middleware order matters — auth first, then rate limit, then business logic
- Use middleware to narrow the context type — protectedProcedure guarantees userId is string
- Extract middleware into reusable functions — requireRole('admin') returns a middleware
Server Actions: Direct tRPC Calls from Forms
Server Actions provide a simple way to call tRPC procedures without HTTP for form submissions and background tasks. They run on the server, have full access to the tRPC context, and can be called directly from forms with progressive enhancement. This is useful for mutations that do not need real-time streaming.
// ============================================ // tRPC + Server Actions — Direct Calls from Forms // ============================================ // ---- Server Action that calls tRPC directly ---- // File: app/actions/user.ts 'use server' import { createServerCaller } from '@/lib/trpc/server-caller' import { revalidatePath } from 'next/cache' export async function updateBio(formData: FormData) { const caller = await createServerCaller() const bio = formData.get('bio') as string try { const updated = await caller.user.updateProfile({ bio }) // Revalidate the dashboard page cache revalidatePath('/dashboard') return { success: true, profile: updated } } catch (error) { if (error instanceof Error) { return { success: false, error: error.message } } return { success: false, error: 'Unknown error' } } } // ---- Client Form using Server Action ---- // File: components/bio-form.tsx 'use client' import { useActionState } from 'react' import { updateBio } from '@/app/actions/user' export function BioForm({ initialBio }: { initialBio: string }) { const [state, formAction, isPending] = useActionState(updateBio, null) return ( <form action={formAction}> <textarea name="bio" defaultValue={initialBio} /> <button type="submit" disabled={isPending}> {isPending ? 'Saving...' : 'Save Bio'} </button> {state && !state.success && ( <p className="text-destructive">{state.error}</p> )} {state && state.success && ( <p className="text-green-600">Bio updated successfully!</p> )} </form> ) }
- Server Actions call tRPC procedures directly via createCaller — no HTTP, no client hooks
- Perfect for forms — progressive enhancement works automatically
RevalidatePath()or revalidateTag() after mutations to update cached pages- Use useActionState (React 19) for pending state and error handling
- Use for mutations that do not need real-time streaming — keep streaming for chat-like flows
Deployment: Testing, Performance, and Production Patterns
Production deployment requires testing (procedure-level unit tests, integration tests for the full request cycle), performance optimization (batch requests, response compression, query deduplication), and monitoring (error rates, latency percentiles, cache hit rates). The httpBatchLink batches multiple procedure calls into a single HTTP request — critical for pages that call multiple procedures.
// ============================================ // tRPC Production Patterns // ============================================ // ---- Pattern 1: Testing procedures ---- // File: __tests__/routers/user.test.ts import { describe, it, expect, vi } from 'vitest' import { appRouter } from '@/server/root' // Create a mock caller for testing function createMockCaller(userId: string | null = 'user-123') { const mockSupabase = { from: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ single: vi.fn().mockResolvedValue({ data: { id: userId, name: 'Test User', email: 'test@example.com' }, error: null, }), }), }), }), } return appRouter.createCaller({ supabase: mockSupabase as any, userId, }) } describe('userRouter', () => { describe('me', () => { it('returns the current user profile', async () => { const caller = createMockCaller('user-123') const result = await caller.user.me() expect(result.id).toBe('user-123') expect(result.name).toBe('Test User') }) it('throws UNAUTHORIZED when not logged in', async () => { const caller = createMockCaller(null) await expect(caller.user.me()).rejects.toThrow('UNAUTHORIZED') }) }) describe('list', () => { it('validates input with Zod', async () => { const caller = createMockCaller() // @ts-expect-error — testing invalid input await expect(caller.user.list({ page: -1 })).rejects.toThrow() }) it('returns paginated results', async () => { const caller = createMockCaller() const result = await caller.user.list({ page: 1, limit: 10 }) expect(result).toHaveProperty('users') expect(result).toHaveProperty('total') expect(result).toHaveProperty('hasMore') }) }) }) // ---- Pattern 2: Response compression & caching ---- // File: app/api/trpc/[trpc]/route.ts (updated) import { fetchRequestHandler } from '@trpc/server/adapters/fetch' const handler = async (req: Request) => { const response = await fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext: async () => { /* ... */ }, responseMeta({ ctx, type, errors }) { // Only cache public queries (no user context) if (type === 'query' && !ctx?.userId && errors.length === 0) { return { headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300', }, } } return {} }, }) return response } export { handler as GET, handler as POST } // ---- Pattern 3: OpenAPI generation ---- // Generate OpenAPI spec from tRPC router for external consumers // npm install trpc-openapi import { generateOpenApiDocument } from 'trpc-openapi' const openApiSpec = generateOpenApiDocument(appRouter, { title: 'Example CRUD API', description: 'OpenAPI compliant REST API built with tRPC', version: '1.0.0', baseUrl: 'https://api.example.com', }) // File: app/api/openapi/route.ts export function GET() { return Response.json(openApiSpec) } // ---- Pattern 4: Next.js config for production ---- // File: next.config.ts import type { NextConfig } from 'next' const nextConfig: NextConfig = { // Increase body size limit for large mutations experimental: { serverActions: { bodySizeLimit: '2mb', }, }, // External packages for server-only code serverExternalPackages: ['@trpc/server'], } export default nextConfig // ---- Pattern 5: Connection pooling for database ---- // File: lib/supabase/pool.ts import { createClient } from '@supabase/supabase-js' // Singleton pattern for connection pooling // WARNING: Never expose SUPABASE_SERVICE_ROLE_KEY in request-scoped code // Use only in isolated server actions with strict auth checks let client: ReturnType<typeof createClient> | null = null export function getPooledClient() { if (!client) { client = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, { auth: { persistSession: false, autoRefreshToken: false, }, db: { schema: 'public', }, } ) } return client }
- Test procedures with createCaller — mock the context, verify input validation and output types
- Use httpBatchLink — batches multiple procedure calls into one HTTP request
- Set CDN cache headers for public queries — s-maxage for CDN, stale-while-revalidate for freshness
- Generate OpenAPI spec from the router — enables external consumers without tRPC client
- Monitor error rates, latency percentiles, and cache hit rates — use structured logging
What tRPC Actually Solves That REST Never Did
REST gives you a contract written in documentation that nobody reads. tRPC gives you a contract enforced at compile time. That's the whole game. When you define a procedure in tRPC, TypeScript knows the input shape, the output shape, and every error path before your app ever hits a browser.
Look at what happens without it. You write a POST handler, validate the body with Zod anyway, parse the response, and pray your frontend types match the backend. One engineer renames a field. Another engineer doesn't get the memo. Suddenly your dashboard shows undefined where the user's name should be. That's a production incident born from a type mismatch, not from bad logic.
tRPC eliminates that entire class of bugs by making the API definition the single source of truth. The router file becomes your API spec. Change the output type in your query, and every consumer breaks at compile time. Not at 3 AM when your pager goes off.
The real insight is that you don't need a separate SDK or code generation tool. tRPC gives you end-to-end type safety with zero schema files. Your TypeScript types are the schema. That's why it compiles for 14 seconds — it's computing the entire type graph of your application. Worth every millisecond.
// io.thecodeforge — javascript tutorial // REST approach: types duplicated, no compiler enforcement interface UserResponse { id: string name: string email: string } // Frontend manually calls fetch and prays const res = await fetch('/api/users/123') const data: UserResponse = await res.json() // If backend returns {id, username, email}, you just shipped a bug // tRPC approach: one source of truth const user = await trpc.user.getById.query({ id: '123' }) // ^ This is fully typed. Rename on the server, break here instantly.
Folder Structure That Scales Without Resentment
Every tRPC project starts clean. Two weeks later, you have a procedures folder with forty files and no idea which router owns what. The fix is boring and intentional: mirror your feature domains.
Don't separate by type. No queries/, mutations/, middleware/ buckets. Do separate by domain. A users router lives in server/routers/users.ts. A billing router lives in server/routers/billing.ts. Each file exports exactly one router with its own input schemas, error types, and middleware.
Your server/trpc.ts initializes the tRPC context and merges the routers. That's it. The folder structure communicates how the app works, not how tRPC works. New engineers onboard faster because they navigate by business logic, not by framework conventions.
A common mistake is putting everything in one giant router file. Don't. You lose tree-shaking advantages, your IDE becomes sluggish, and code review turns into an archaeology dig. Keep routers small — five to fifteen procedures max. If a router exceeds that, your domain boundary is wrong. Split again.
// io.thecodeforge — javascript tutorial src/ app/ # Next.js App Router pages server/ trpc.ts # tRPC init, context factory routers/ users.ts # getById, updateProfile, deleteAccount billing.ts # getSubscription, changePlan, getInvoices products.ts middleware/ auth.ts # verifySession, attachUser rateLimiter.ts shared/ schemas.ts # Shared Zod schemas between routers types.ts # User, Product, etc.
router.merge('users.', usersRouter) becomes trpc.users.getById.query(). Two levels deep is already annoying. Three is a crime./trpc/query-client.ts: Stop Rewriting Query Client Config
You don't need to copy-paste the TanStack Query client into every project. That's cargo-cult engineering. The query client is your cache layer — misconfigure it and you're debugging stale data at 2 AM. The setup is ten lines, but every line has a production consequence.
Default staleTime to 30 seconds unless you like waterfalls. Leave gcTime at 5 minutes so back-navigation doesn't re-fetch. Skip the devtools in production — that's a bundle size tax nobody asked for. Export the client as a singleton so every consumer gets the same cache instance.
This file is your first line of defense against unnecessary network calls. Treat it with respect, or your database will send you a bill.
// io.thecodeforge — javascript tutorial import { QueryClient } from '@tanstack/react-query' function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { staleTime: 30 * 1000, gcTime: 5 * 60 * 1000, retry: 1, refetchOnWindowFocus: false, }, }, }) } let browserQueryClient: QueryClient | undefined export function getQueryClient() { if (typeof window === 'undefined') { return makeQueryClient() } if (!browserQueryClient) { browserQueryClient = makeQueryClient() } return browserQueryClient }
/trpc/server.tsx: The File That Connects Server Components to Your Router
Every tRPC-in-Next.js setup needs a server-side caller. Without it, your server components can't execute procedures directly — they'd have to make HTTP calls to themselves, which is the dumbest possible architecture. This file creates a typed caller that bypasses the network entirely.
You export a function that creates a caller context, typically with request headers for auth. The magic is in createCallerFactory — it takes your router and returns a fully typed caller. No fetch, no JSON parse, just direct function calls with full type safety.
Why this matters: your page component calls db.getUser directly, not via an API route. That means zero latency for data your page already has on the server. Pair this with React's cache() and your database calls become idempotent across the request lifecycle.
// io.thecodeforge — javascript tutorial import 'server-only' import { createCallerFactory } from '@trpc/server' import { headers } from 'next/headers' import { cache } from 'react' import { appRouter } from './router' import { createTRPCContext } from './init' const createCaller = createCallerFactory(appRouter) export const getServerClient = cache(async () => { const h = await headers() const ctx = await createTRPCContext({ headers: h }) return createCaller(ctx) })
React.cache() to deduplicate calls within the same request. Without it, two components on the same page trigger two database queries./trpc/init.ts: The Bootstrap File That Prevents CORS Nightmares
Every tRPC setup needs a single source of truth for initialization. /trpc/init.ts creates the tRPC instance with shared context, transformer, and error formatter. This file prevents duplicate configurations across server and client boundaries. Define t = initTRPC.context<Context>().create() once, then import t everywhere else. Without it, you'll fight CORS errors, mismatched serialization, and context drift. The transformer (SuperJSON recommended) handles Date, Map, and Set objects that JSON.stringify silently corrupts. Production setups add error formatting here—catch Zod validation errors early. This is the file your router, server, and client all import. Miss it, and you'll have three different tRPC instances fighting each other.
// io.thecodeforge — javascript tutorial import { initTRPC } from '@trpc/server'; import { getHTTPStatusCodeFromError } from '@trpc/server/http'; import superjson from 'superjson'; import { ZodError } from 'zod'; const t = initTRPC.context<Context>().create({ transformer: superjson, errorFormatter: ({ shape, error }) => ({ ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }), }); export const router = t.router; export const publicProcedure = t.procedure; export { t };
t directly from router files. Keep it isolated in /trpc/init.ts to avoid circular imports when server components need context./trpc/router.ts: The Single File That Kills File-Scattered Route Chaos
Stop hunting for procedures across 20 files. /trpc/router.ts is the central merge point for all sub-routers. Import t from init, create sub-routers in dedicated files (e.g., postRouter, userRouter), then merge them in this file with t.router({ post: postRouter, user: userRouter }). This pattern mirrors Next.js route groups without the mental overhead. Each sub-router has its own middleware, input schemas, and error handling. The router file exports a single AppRouter type that client code consumes via inferRouterOutputs. Scaling means adding a file, importing it here, and moving on—no spaghetti, no duplication.
// io.thecodeforge — javascript tutorial import { t } from './init'; import { postRouter } from './routers/post'; import { userRouter } from './routers/user'; import { healthRouter } from './routers/health'; const appRouter = t.router({ post: postRouter, user: userRouter, health: healthRouter, }); export type AppRouter = typeof appRouter; export default appRouter;
/routers/ with one procedure per file. Merging 10+ routers without namespacing slows IDE autocompletion and fires up tRPC inference overhead.Monolithic tRPC Router Caused 14-Second TypeScript Compilation
- Split routers by domain — never put all procedures in one file.
- TypeScript infers the entire router type on every change — large routers cause exponential compilation slowdown.
- 30 procedures per router file is a reasonable upper limit — beyond that, split further.
- Monitor TypeScript compilation time in CI — it is an early warning sign of router bloat.
trpc.path.list.invalidate() for type-safe invalidationgrep -rn 'export type AppRouter' server/ --include='*.ts' | head -5grep -rn 'createTRPCNext\|trpc\.' app/ components/ --include='*.ts' --include='*.tsx' | head -10grep -rn '\.query\|\.mutation' server/routers/ --include='*.ts' | head -20cat server/root.tsnpx tsc --noEmit --diagnostics 2>&1 | grep -i 'time\|files'wc -l server/routers/*.ts | sort -rn | head -10grep -rn 'invalidateQueries\|setQueryData' components/ --include='*.tsx' | head -10grep -rn 'useMutation\|useQuery' components/ --include='*.tsx' | head -10trpc.path.list.invalidate() in the mutation's onSuccess callback to refetch affected queries| Feature | tRPC | REST (Next.js Route Handlers) | GraphQL | Server Actions |
|---|---|---|---|---|
| Type Safety | End-to-end inferred | Manual or codegen | Codegen required | Partial (input only) |
| Client DX | Autocomplete, no fetch | Manual fetch calls | Query strings | Direct function calls |
| Caching | React Query integration | Manual (fetch cache) | Apollo/urql cache | None built-in |
| Real-time | Subscriptions (WebSocket) | SSE or WebSocket | Subscriptions | Not supported |
| External API | trpc-openapi adapter | Native | Native | Not designed for external use |
| Bundle Size | ~15KB client | ~0KB (native fetch) | ~30KB (Apollo) | ~0KB (server only) |
| Learning Curve | Low (TypeScript) | Low | High | Low |
| Best For | Full-stack TypeScript apps | Public APIs, microservices | Complex data graphs | Simple mutations, forms |
| File | Command / Code | Purpose |
|---|---|---|
| io.thecodeforge.trpc.setup.ts | type TRPCContext = { | Project Setup |
| io.thecodeforge.trpc.procedures.ts | export const userRouter = createTRPCRouter({ | Procedures |
| io.thecodeforge.trpc.server-components.ts | export async function createServerCaller() { | Server Components |
| io.thecodeforge.trpc.client-hooks.tsx | 'use client' | Client-Side Hooks |
| io.thecodeforge.trpc.error-handling.ts | const t = initTRPC.context | Error Handling |
| io.thecodeforge.trpc.middleware.ts | const rateLimitMap = new Map | Middleware |
| io.thecodeforge.trpc.server-actions.ts | 'use server' | Server Actions |
| io.thecodeforge.trpc.production.ts | function createMockCaller(userId: string | null = 'user-123') { | Deployment |
| RESTvsTRPC.ts | interface UserResponse { | What tRPC Actually Solves That REST Never Did |
| folderStructure.txt | src/ | Folder Structure That Scales Without Resentment |
| function makeQueryClient() { | /trpc/query-client.ts | |
| const createCaller = createCallerFactory(appRouter) | /trpc/server.tsx | |
| const t = initTRPC.context | /trpc/init.ts | |
| const appRouter = t.router({ | /trpc/router.ts |
Key takeaways
Common mistakes to avoid
8 patternsMonolithic router with 100+ procedures in one file
Forgetting to invalidate queries after mutations
utils.path.list.invalidate() (or the specific query) in the mutation's onSuccess callback. Use type-safe invalidation helpers from trpc.useUtils().Not using superjson as the transformer
Exposing stack traces or internal errors to the client
Using queries for write operations
No input validation — accepting raw input in procedures
Not batching requests — calling multiple procedures separately
Calling client hooks in Server Components
Interview Questions on This Topic
How does tRPC provide end-to-end type safety, and how is it different from REST or GraphQL?
What is the difference between calling a tRPC procedure in a Server Component vs a Client Component?
How do you handle errors in tRPC, and what should be exposed to the client?
Why should you split tRPC routers by domain, and what happens if you don't?
What is the purpose of superjson in tRPC, and what happens without it?
Frequently Asked Questions
Yes. tRPC has adapters for React (React Query), Vue, Svelte, Solid, and vanilla JavaScript. The client works with any frontend framework that can make HTTP requests. The server is framework-agnostic — it can run on Express, Fastify, Hono, or any Node.js server. The Next.js integration is one of several options.
Use the trpc-openapi package. It wraps your tRPC router and generates an OpenAPI 3.0 spec. This enables external consumers (mobile apps, third-party integrations) to use your API without a tRPC client. The package requires OpenAPI metadata on each procedure (method, path, description, tags).
Yes. tRPC is database-agnostic — the procedures can use any data source: Prisma, Drizzle, Kysely, raw SQL, REST APIs, or even in-memory data. The Supabase examples in this article are for illustration — replace the supabase client calls with your preferred ORM or database client.
tRPC does not natively support file uploads through the JSON-based procedure interface. For file uploads, use a separate Next.js Route Handler that accepts FormData, upload the file to your storage provider (S3, Supabase Storage), and return the file URL. Then call a tRPC mutation with the file URL to associate it with your data model.
Use createCaller to test procedures directly — it runs the resolver with a mocked context, bypassing HTTP. Mock the Supabase client and any external dependencies. Test input validation by passing invalid data and asserting that TRPCError is thrown. Test the output type by asserting the return value matches your expectations. Use vitest or jest as the test runner.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
That's React.js. Mark it forged?
7 min read · try the examples if you haven't