Skip to content
Homeβ€Ί JavaScriptβ€Ί tRPC v11 + Next.js 16: Complete Setup and Best Practices

tRPC v11 + Next.js 16: Complete Setup and Best Practices

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: React.js β†’ Topic 27 of 32
Build end-to-end type-safe APIs with tRPC v11 and Next.
πŸ”₯ Advanced β€” solid JavaScript foundation required
In this tutorial, you'll learn
Build end-to-end type-safe APIs with tRPC v11 and Next.
  • 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
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑Quick Answer
  • 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
🚨 START HERE
tRPC Quick Debug Reference
Fast commands for diagnosing tRPC type, procedure, and cache issues
🟑Types not inferring on client
Immediate ActionCheck AppRouter type export and import
Commands
grep -rn 'export type AppRouter' server/ --include='*.ts' | head -5
grep -rn 'createTRPCNext\|trpc\.' app/ components/ --include='*.ts' --include='*.tsx' | head -10
Fix NowEnsure AppRouter is exported from the root router and imported correctly in the client setup file
🟑Procedure not found error
Immediate ActionVerify the procedure path matches the router structure
Commands
grep -rn '\.query\|\.mutation' server/routers/ --include='*.ts' | head -20
cat server/root.ts
Fix NowCheck that the procedure is registered in the correct sub-router and the sub-router is merged into the root router
🟠Slow TypeScript compilation
Immediate ActionMeasure compilation time and identify large routers
Commands
npx tsc --noEmit --diagnostics 2>&1 | grep -i 'time\|files'
wc -l server/routers/*.ts | sort -rn | head -10
Fix NowSplit routers exceeding 30 procedures into smaller domain-specific files
🟑React Query cache stale after mutation
Immediate ActionCheck invalidation calls in mutation onSuccess
Commands
grep -rn 'invalidateQueries\|setQueryData' components/ --include='*.tsx' | head -10
grep -rn 'useMutation\|useQuery' components/ --include='*.tsx' | head -10
Fix NowAdd trpc.path.list.invalidate() in the mutation's onSuccess callback to refetch affected queries
Production IncidentMonolithic tRPC Router Caused 14-Second TypeScript CompilationA team's single tRPC router file grew to 3,200 lines with 180 procedures. TypeScript compilation took 14 seconds, IDE autocomplete lagged by 3-5 seconds, and adding a new procedure broke type inference across the entire application.
SymptomDevelopers reported 3-5 second autocomplete delays in VS Code. TypeScript compilation took 14 seconds on every save (incremental). Adding a single procedure to the router caused a full re-inference of all client-side hooks, taking 8 seconds. The CI build time increased from 45 seconds to 4 minutes. Three developers reported they stopped using autocomplete entirely and wrote procedure calls from memory.
AssumptionA single router file was simpler to manage. The team assumed TypeScript could handle any number of procedures without performance degradation. They did not realize that tRPC infers the entire router type on every change.
Root causeThe router was a single file with 180 procedures across 12 domains (users, posts, comments, billing, analytics, notifications, search, uploads, webhooks, admin, auth, settings). TypeScript had to infer the type of the entire router β€” including all input schemas, output schemas, middleware chains, and error types β€” on every file change. The inferred type was approximately 45,000 lines of TypeScript type definitions. IDE language services could not cache this effectively, causing the autocomplete lag. The client-side hooks (trpc.user.get.useQuery) were derived from the full router type, so any change to any procedure invalidated the type for all hooks.
FixSplit the monolithic router into 12 domain-specific routers (userRouter, postRouter, commentRouter, etc.) and merged them with createTRPCRouter. Each domain router was in its own file. TypeScript now infers each router's type independently β€” adding a procedure to userRouter does not re-infer postRouter. Compilation time dropped from 14 seconds to 2.1 seconds. IDE autocomplete returned to sub-500ms. Added a lint rule that enforces a maximum of 30 procedures per router file.
Key Lesson
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.
Production Debug GuideDiagnose type inference, procedure calls, and error handling issues
Client hooks show 'any' type instead of inferred types→Check that the client imports the correct AppRouter type from the server — the type must match the exported router
Procedure returns 500 error with no details→Check the error formatter in the tRPC context — ensure TRPCError is thrown with a message, not a raw Error
Server Component procedure call returns serialized data instead of live objects→Use the server caller (createCaller) — not the client proxy. The caller runs procedures directly without serialization.
React Query cache not updating after mutation→Verify invalidateQueries is called with the correct query key — use trpc.path.list.invalidate() for type-safe invalidation
Input validation passes on client but fails on server→Check that the Zod schema is identical on both sides — tRPC uses the server schema, but client-side defaults may differ
TypeScript compilation takes over 10 seconds→Split the router into domain-specific files — each with 30 or fewer procedures. Large routers cause exponential type inference time.

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.

io.thecodeforge.trpc.setup.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
// ============================================
// 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>
  )
}
Mental Model
tRPC Architecture: Server, Router, Client
tRPC connects server procedures to client calls with zero type gaps β€” the router is the contract.
  • 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
πŸ“Š Production Insight
tRPC v11 uses fetchRequestHandler for App Router β€” not the Pages Router adapter.
superjson handles Date, Map, Set, BigInt serialization β€” without it, complex types are lost.
Rule: always use superjson as the transformer β€” plain JSON cannot handle non-primitive types.
🎯 Key Takeaway
Four files to set up: context, API route, root router, client provider β€” each handles one concern.
superjson is mandatory for complex types β€” without it, Date and Map serialize incorrectly.
The AppRouter type is the contract β€” export it from the server, import it in the client.

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.

io.thecodeforge.trpc.procedures.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
// ============================================
// 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
    }),
})
⚠ Query vs Mutation: Know the Difference
πŸ“Š Production Insight
Queries are cached by React Query β€” mutations are not. Using a query for writes breaks caching.
Output schemas validate the resolver return value β€” catch bugs before data reaches the client.
Rule: queries for reads, mutations for writes β€” always validate inputs with Zod.
🎯 Key Takeaway
Procedures are the API surface β€” each has input schema, output schema, and resolver.
Zod schemas serve three purposes: runtime validation, type inference, and documentation.
Cursor-based pagination is more efficient than offset for large datasets β€” use cursor for infinite scroll.

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.

io.thecodeforge.trpc.server-components.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
// ============================================
// 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()
  }
}
Mental Model
Server Caller vs Client Hooks: Two Ways to Call Procedures
Server Components call procedures directly (no HTTP). Client Components use React Query hooks (HTTP + cache).
  • 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
πŸ“Š Production Insight
Server caller bypasses HTTP entirely β€” no network round-trip, no serialization overhead.
Pass server-fetched data as initialData to client hooks β€” eliminates loading state flash on initial render for interactivity β€” combine both for optimal UX.
🎯 Key Takeaway
createCaller runs procedures directly with the same context β€” no HTTP, no serialization.
Server Components fetch data during SSR β€” pass as initialData to Client Components.
The hybrid pattern: server fetches initial data, client handles interactivity and mutations.

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.

io.thecodeforge.trpc.client-hooks.tsx Β· TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
// ============================================
// 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>
  )
}
πŸ’‘Cache Management Patterns
  • 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
πŸ“Š Production Insight
Optimistic updates make the UI feel instant β€” but always implement rollback on error.
Prefetch on hover eliminates loading states for navigation β€” data is ready before the click.
Rule: invalidate after mutation, optimistic update for instant UX, rollback on error.
🎯 Key Takeaway
useQuery caches data with stale-while-revalidate β€” served from cache instantly, refetched in background.
useMutation triggers writes β€” always invalidate related queries in onSuccess.
Optimistic updates + rollback on error = instant UX with safety net.

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.

io.thecodeforge.trpc.error-handling.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
// ============================================
// 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>
  )
}
⚠ Error Handling Rules
πŸ“Š Production Insight
The error formatter controls what reaches the client β€” never expose stack traces in production.
Zod validation errors should be field-level β€” the client maps them to form fields for UX.
Rule: log errors server-side with context, return structured codes to the client.
🎯 Key Takeaway
TRPCError provides structured error codes β€” UNAUTHORIZED, NOT_FOUND, BAD_REQUEST, FORBIDDEN.
The error formatter controls what reaches the client β€” never expose internal details.
Zod errors are returned as field-level errors β€” map them to form fields for user feedback.

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.

io.thecodeforge.trpc.middleware.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
// ============================================
// 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 }
    }),
})
πŸ’‘Middleware Composition Pattern
  • 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
πŸ“Š Production Insight
Middleware runs in order β€” auth first, then rate limit, then the resolver.
Use middleware to narrow context types β€” protectedProcedure guarantees userId is string.
Rule: one concern per middleware, compose them into procedure variants.
🎯 Key Takeaway
Middleware wraps procedures with cross-cutting concerns β€” auth, rate limiting, logging.
Compose middleware into procedure variants β€” publicProcedure, protectedProcedure, adminProcedure.
Middleware narrows context types β€” protectedProcedure guarantees userId is a non-null string.

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.

io.thecodeforge.trpc.server-actions.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// ============================================
// 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 + tRPC
  • 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
πŸ“Š Production Insight
Server Actions run tRPC procedures directly β€” no network overhead.
Use revalidatePath after mutations to keep cached pages fresh.
Rule: Server Actions for form submissions, streaming Route Handlers for real-time chat.
🎯 Key Takeaway
Server Actions call tRPC procedures directly via createCaller β€” no HTTP.
Perfect for form submissions with progressive enhancement.
Revalidate cached pages after mutations to keep the UI in sync.

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.

io.thecodeforge.trpc.production.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
// ============================================
// 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
}
πŸ”₯Production Checklist
  • 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
πŸ“Š Production Insight
httpBatchLink batches multiple procedure calls into one HTTP request β€” reduces network overhead.
CDN cache headers on public queries reduce server load β€” s-maxage for CDN, stale-while-revalidate.
Rule: test with createCaller, batch requests, cache at CDN level, monitor error rates.
🎯 Key Takeaway
Test procedures with createCaller β€” mock the context, verify validation and output.
Batch requests with httpBatchLink β€” multiple calls in one HTTP request reduces latency.
CDN caching on public queries reduces server load β€” set appropriate cache headers.
πŸ—‚ API Approaches Compared
tRPC vs REST vs GraphQL for Next.js applications
FeaturetRPCREST (Next.js Route Handlers)GraphQLServer Actions
Type SafetyEnd-to-end inferredManual or codegenCodegen requiredPartial (input only)
Client DXAutocomplete, no fetchManual fetch callsQuery stringsDirect function calls
CachingReact Query integrationManual (fetch cache)Apollo/urql cacheNone built-in
Real-timeSubscriptions (WebSocket)SSE or WebSocketSubscriptionsNot supported
External APItrpc-openapi adapterNativeNativeNot designed for external use
Bundle Size~15KB client~0KB (native fetch)~30KB (Apollo)~0KB (server only)
Learning CurveLow (TypeScript)LowHighLow
Best ForFull-stack TypeScript appsPublic APIs, microservicesComplex data graphsSimple 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

    βœ•Monolithic router with 100+ procedures in one file
    Symptom

    TypeScript compilation takes 10+ seconds. IDE autocomplete lags by 3-5 seconds. Adding one procedure invalidates types for all procedures. Developers stop using autocomplete.

    Fix

    Split routers by domain (userRouter, postRouter, billingRouter). Keep each router file under 30 procedures. Merge with createTRPCRouter in the root router.

    βœ•Forgetting to invalidate queries after mutations
    Symptom

    User edits a post and sees the old content until they manually refresh. The mutation succeeds on the server but the UI shows stale data from the cached query.

    Fix

    Call 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
    Symptom

    Date objects arrive as ISO strings on the client. Map and Set serialize as empty objects. BigInt loses precision. The client has to manually reconstruct types.

    Fix

    Set transformer: superjson in both the tRPC init and the client httpBatchLink. superjson handles Date, Map, Set, BigInt, and other non-JSON-serializable types.

    βœ•Exposing stack traces or internal errors to the client
    Symptom

    Error responses include file paths, line numbers, and database query details. Security vulnerability β€” attackers can learn about the internal architecture.

    Fix

    Use the error formatter to control what reaches the client. Log the full error server-side with context. Return only the error code and a user-friendly message to the client.

    βœ•Using queries for write operations
    Symptom

    Data mutations are cached by React Query β€” subsequent reads return stale data. The cache does not know the data changed because queries are assumed to be idempotent.

    Fix

    Use mutations for all write operations. Mutations are not cached and trigger invalidation of related queries. Queries should be read-only and idempotent.

    βœ•No input validation β€” accepting raw input in procedures
    Symptom

    Procedures receive invalid data types β€” strings where numbers are expected, missing required fields. Runtime errors occur deep in the resolver instead of at the boundary.

    Fix

    Add Zod schemas to every procedure's input. Zod validates before the resolver runs β€” invalid input throws a BAD_REQUEST error with field-level details.

    βœ•Not batching requests β€” calling multiple procedures separately
    Symptom

    A page that calls 5 procedures makes 5 separate HTTP requests. Network overhead compounds β€” 5 x 50ms = 250ms of unnecessary latency.

    Fix

    Use httpBatchLink in the client setup. It batches multiple procedure calls into a single HTTP request. All 5 calls travel in one network round-trip.

    βœ•Calling client hooks in Server Components
    Symptom

    Error: 'useQuery cannot be used in a Server Component'. The hook requires React Query context which is only available on the client.

    Fix

    Use createCaller in Server Components β€” call procedures directly without hooks. Use hooks only in Client Components (with 'use client'). Pass server-fetched data as props to Client Components.

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
    tRPC infers types directly from the server router definition. When you define a procedure with an input schema (Zod) and a resolver, TypeScript knows the input type, output type, and the procedure path. The client imports the AppRouter type and gets full autocomplete β€” trpc.user.me.useQuery() knows the input shape and the return type without any manual type definitions. REST requires manual type definitions or code generation (OpenAPI codegen). GraphQL requires schema definitions and code generation (graphql-codegen). tRPC requires neither β€” the types flow automatically through TypeScript inference. The trade-off: tRPC is TypeScript-only and does not produce a standard API spec (though trpc-openapi can generate one).
  • QWhat is the difference between calling a tRPC procedure in a Server Component vs a Client Component?Mid-levelReveal
    Server Components use createCaller β€” it runs the procedure's resolver directly with the same context and middleware as an HTTP request, but without the HTTP layer. There is no network round-trip, no serialization, and no React Query cache. The data is fetched during server-side rendering. Client Components use React Query hooks (useQuery, useMutation) β€” these send an HTTP request to the /api/trpc endpoint, which runs the procedure and returns the result. The response is cached by React Query, enabling stale-while-revalidate, background refetch, and optimistic updates. The hybrid pattern: Server Components fetch initial data via createCaller and pass it as props to Client Components. Client Components use hooks with initialData to avoid a loading state flash on initial render.
  • QHow do you handle errors in tRPC, and what should be exposed to the client?SeniorReveal
    tRPC uses TRPCError with structured codes (UNAUTHORIZED, NOT_FOUND, BAD_REQUEST, FORBIDDEN, INTERNAL_SERVER_ERROR, TOO_MANY_REQUESTS). Each error has a code and a message. The error formatter on the server controls what reaches the client. It should include: the error code (for client-side conditional rendering), a user-friendly message, and Zod validation errors as field-level errors (for form feedback). It should never include: stack traces, file paths, database query details, or internal error objects. On the client, errors are available through the hook's error property. The client checks error.data?.code to render appropriate UI (login redirect for UNAUTHORIZED, 404 page for NOT_FOUND, form field errors for BAD_REQUEST).
  • QWhy should you split tRPC routers by domain, and what happens if you don't?SeniorReveal
    TypeScript infers the entire router type on every file change. A monolithic router with 180 procedures produces an inferred type of approximately 45,000 lines of TypeScript definitions. IDE language services cannot cache this effectively, causing 3-5 second autocomplete delays. Adding one procedure to the router invalidates the type for all client-side hooks, triggering a full re-inference. Splitting by domain (userRouter with 15 procedures, postRouter with 12 procedures, etc.) allows TypeScript to infer each router's type independently. Adding a procedure to userRouter does not re-infer postRouter. This reduces compilation time from 14 seconds to 2-3 seconds and restores sub-500ms autocomplete.
  • QWhat is the purpose of superjson in tRPC, and what happens without it?JuniorReveal
    superjson is a serialization library that handles JavaScript types that JSON cannot represent: Date objects, Map, Set, BigInt, RegExp, undefined, NaN, Infinity, and circular references. Without superjson, these types are serialized incorrectly β€” Date becomes an ISO string (losing prototype methods), Map and Set become empty objects, BigInt loses precision, and undefined becomes null. tRPC uses superjson as a transformer β€” it runs on both the server (serializing the response) and the client (deserializing the response). The transformer must be configured in both initTRPC.create({ transformer: superjson }) and httpBatchLink({ transformer: superjson }). Without matching configuration on both sides, deserialization fails silently.

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.

πŸ”₯
Naren Founder & Author

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.

← PreviousSupabase Auth with Next.js 16 β€” The Complete 2026 GuideNext β†’How to Build an AI Agent with Next.js, LangChain & Supabase
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged