Junior 4 min · April 12, 2026

tRPC Router Bloat — 14-Second Compilation in Next.js 16

A tRPC router with 180 procedures caused 14-second compilation and 3-5 sec autocomplete lag.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
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
Plain-English First

tRPC is like a contract between your frontend and backend. You define functions (procedures) on the server with input and output types. On the client, you call those functions as if they were local — you get autocomplete, type checking, and compile-time errors if the types do not match. No manual API type definitions, no code generation step, no schema files to sync. The types flow automatically from the server to the client through TypeScript inference.

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.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// ============================================
// 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>
  )
}
tRPC Architecture: Server, Router, Client
  • 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.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
// ============================================
// 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
  • Queries are for reads — cached by React Query, refetched on stale, idempotent
  • Mutations are for writes — not cached, trigger invalidation of related queries
  • Using a query for a write operation breaks the cache model — data becomes stale
  • Using a mutation for a read loses caching benefits — every call hits the server
  • The distinction affects React Query behavior — not just HTTP semantics
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.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
// ============================================
// 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 vs Client Hooks: Two Ways to Call Procedures
  • 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.tsxTSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
// ============================================
// 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.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
// ============================================
// 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
  • Never expose stack traces or internal error details to the client — use the error formatter
  • Always include an error code — clients use it for conditional rendering, not error.message
  • Zod validation errors should be returned as field-level errors — not a single message string
  • Log all errors server-side with context (userId, path, type, duration) — for observability
  • Error cause is logged on the server but never sent to the client — prevents information leakage
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.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
// ============================================
// 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.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// ============================================
// 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.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
// ============================================
// 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.
● Production incidentPOST-MORTEMseverity: high

Monolithic tRPC Router Caused 14-Second TypeScript Compilation

Symptom
Developers 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.
Assumption
A 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 cause
The 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.
Fix
Split 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 issues6 entries
Symptom · 01
Client hooks show 'any' type instead of inferred types
Fix
Check that the client imports the correct AppRouter type from the server — the type must match the exported router
Symptom · 02
Procedure returns 500 error with no details
Fix
Check the error formatter in the tRPC context — ensure TRPCError is thrown with a message, not a raw Error
Symptom · 03
Server Component procedure call returns serialized data instead of live objects
Fix
Use the server caller (createCaller) — not the client proxy. The caller runs procedures directly without serialization.
Symptom · 04
React Query cache not updating after mutation
Fix
Verify invalidateQueries is called with the correct query key — use trpc.path.list.invalidate() for type-safe invalidation
Symptom · 05
Input validation passes on client but fails on server
Fix
Check that the Zod schema is identical on both sides — tRPC uses the server schema, but client-side defaults may differ
Symptom · 06
TypeScript compilation takes over 10 seconds
Fix
Split the router into domain-specific files — each with 30 or fewer procedures. Large routers cause exponential type inference time.
★ tRPC Quick Debug ReferenceFast commands for diagnosing tRPC type, procedure, and cache issues
Types not inferring on client
Immediate action
Check 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 now
Ensure AppRouter is exported from the root router and imported correctly in the client setup file
Procedure not found error+
Immediate action
Verify the procedure path matches the router structure
Commands
grep -rn '\.query\|\.mutation' server/routers/ --include='*.ts' | head -20
cat server/root.ts
Fix now
Check that the procedure is registered in the correct sub-router and the sub-router is merged into the root router
Slow TypeScript compilation+
Immediate action
Measure 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 now
Split routers exceeding 30 procedures into smaller domain-specific files
React Query cache stale after mutation+
Immediate action
Check 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 now
Add trpc.path.list.invalidate() in the mutation's onSuccess callback to refetch affected queries
API Approaches Compared
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

1
tRPC provides end-to-end type safety
the router is the contract between server and client
2
Split routers by domain
monolithic routers cause exponential TypeScript compilation slowdown
3
Server Components use createCaller
no HTTP, no serialization, no React Query cache
4
Always invalidate queries after mutations
stale data is the most common user-facing bug
5
superjson is mandatory for complex types
Date, Map, Set, BigInt serialize incorrectly without it
6
httpBatchLink batches multiple procedure calls into one HTTP request
reduces network overhead

Common mistakes to avoid

8 patterns
×

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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does tRPC provide end-to-end type safety, and how is it different fr...
Q02SENIOR
What is the difference between calling a tRPC procedure in a Server Comp...
Q03SENIOR
How do you handle errors in tRPC, and what should be exposed to the clie...
Q04SENIOR
Why should you split tRPC routers by domain, and what happens if you don...
Q05JUNIOR
What is the purpose of superjson in tRPC, and what happens without it?
Q01 of 05SENIOR

How does tRPC provide end-to-end type safety, and how is it different from REST or GraphQL?

ANSWER
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).
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can tRPC be used with a non-Next.js frontend?
02
How do I generate an OpenAPI spec from a tRPC router?
03
Can I use tRPC with a database other than Supabase?
04
How do I handle file uploads with tRPC?
05
How do I test tRPC procedures?
🔥

That's React.js. Mark it forged?

4 min read · try the examples if you haven't

Previous
Supabase Auth with Next.js 16 — The Complete 2026 Guide
27 / 47 · React.js
Next
How to Build an AI Agent with Next.js, LangChain & Supabase