tRPC v11 + Next.js 16: Complete Setup and Best Practices
- tRPC provides end-to-end type safety β the router is the contract between server and client
- Split routers by domain β monolithic routers cause exponential TypeScript compilation slowdown
- Server Components use createCaller β no HTTP, no serialization, no React Query cache
- 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
Types not inferring on client
grep -rn 'export type AppRouter' server/ --include='*.ts' | head -5grep -rn 'createTRPCNext\|trpc\.' app/ components/ --include='*.ts' --include='*.tsx' | head -10Procedure not found error
grep -rn '\.query\|\.mutation' server/routers/ --include='*.ts' | head -20cat server/root.tsSlow TypeScript compilation
npx tsc --noEmit --diagnostics 2>&1 | grep -i 'time\|files'wc -l server/routers/*.ts | sort -rn | head -10React Query cache stale after mutation
grep -rn 'invalidateQueries\|setQueryData' components/ --include='*.tsx' | head -10grep -rn 'useMutation\|useQuery' components/ --include='*.tsx' | head -10Production Incident
Production Debug GuideDiagnose type inference, procedure calls, and error handling issues
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.
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 }), })
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> ) }
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
| 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 |
π― Key Takeaways
- tRPC provides end-to-end type safety β the router is the contract between server and client
- Split routers by domain β monolithic routers cause exponential TypeScript compilation slowdown
- Server Components use createCaller β no HTTP, no serialization, no React Query cache
- Always invalidate queries after mutations β stale data is the most common user-facing bug
- superjson is mandatory for complex types β Date, Map, Set, BigInt serialize incorrectly without it
- httpBatchLink batches multiple procedure calls into one HTTP request β reduces network overhead
β Common Mistakes to Avoid
Interview Questions on This Topic
- QHow does tRPC provide end-to-end type safety, and how is it different from REST or GraphQL?Mid-levelReveal
- QWhat is the difference between calling a tRPC procedure in a Server Component vs a Client Component?Mid-levelReveal
- QHow do you handle errors in tRPC, and what should be exposed to the client?SeniorReveal
- QWhy should you split tRPC routers by domain, and what happens if you don't?SeniorReveal
- QWhat is the purpose of superjson in tRPC, and what happens without it?JuniorReveal
Frequently Asked Questions
Can tRPC be used with a non-Next.js frontend?
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.
How do I generate an OpenAPI spec from a tRPC router?
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).
Can I use tRPC with a database other than Supabase?
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.
How do I handle file uploads with tRPC?
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.
How do I test tRPC procedures?
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.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.