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
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 contextimport { initTRPC, TRPCError } from'@trpc/server'import superjson from'superjson'import { createClient } from'@/lib/supabase/server'// Define the context type — available in all procedurestypeTRPCContext = {
supabase: Awaited<ReturnType<typeof createClient>>
userId: string | null
}
// Initialize tRPC with the context typeconst t = initTRPC.context<TRPCContext>().create({
transformer: superjson, // Handles Date, Map, Set, BigInt serialization
})
// Export reusable building blocksexportconst createTRPCRouter = t.router
exportconst publicProcedure = t.procedure
exportconst protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.userId) {
thrownewTRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in to access this resource',
})
}
returnnext({
ctx: {
...ctx,
userId: ctx.userId, // Narrowed to string — no longer nullable
},
})
})
// ---- Step 3: Create the API route handler ----// File: app/api/trpc/[trpc]/route.tsimport { 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 = awaitcreateClient()
const { data: { user } } = await supabase.auth.getUser()
return {
supabase,
userId: user?.id ?? null,
}
},
})
export { handler asGET, handler asPOST }
// ---- Step 4: Create the root router ----// File: server/root.tsimport { createTRPCRouter } from'./trpc'import { userRouter } from'./routers/user'import { postRouter } from'./routers/post'import { commentRouter } from'./routers/comment'import { billingRouter } from'./routers/billing'exportconst appRouter = createTRPCRouter({
user: userRouter,
post: postRouter,
comment: commentRouter,
billing: billingRouter,
})
// Export the router type — this is what the client uses for type inferenceexporttypeAppRouter = typeof appRouter
// ---- Step 5: Create the client ----// File: lib/trpc/client.tsimport { createTRPCReact } from'@trpc/tanstack-react-query'importtype { AppRouter } from'@/server/root'exportconst 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'exportfunctionTRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => newQueryClient({
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.tsximport { TRPCProvider } from'@/lib/trpc/provider'exportdefaultfunctionRootLayout({ 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.
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.
// ============================================// 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 HTTPimport { appRouter } from'@/server/root'import { createClient } from'@/lib/supabase/server'exportasyncfunctioncreateServerCaller() {
const supabase = awaitcreateClient()
const { data: { user } } = await supabase.auth.getUser()
// createCaller runs procedures with the same context as HTTP requests// But it bypasses the HTTP layer entirelyreturn appRouter.createCaller({
supabase,
userId: user?.id ?? null,
})
}
// ---- Server Component: Fetch user profile ----// File: app/dashboard/page.tsximport { createServerCaller } from'@/lib/trpc/server-caller'import { redirect } from'next/navigation'exportdefaultasyncfunctionDashboardPage() {
const caller = awaitcreateServerCaller()
try {
// Direct procedure call — no HTTP, no serialization// Full type safety — autocomplete works on caller.user.meconst profile = await caller.user.me()
return (
<div>
<h1>Welcome, {profile.name}</h1>
<p>{profile.bio}</p>
</div>
)
} catch (error) {
// tRPCError from the protectedProcedure middlewareredirect('/login')
}
}
// ---- Server Component: Fetch paginated posts ----// File: app/posts/page.tsximport { createServerCaller } from'@/lib/trpc/server-caller'import { PostList } from'@/components/post-list'exportdefaultasyncfunctionPostsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>
}) {
const params = await searchParams
const page = parseInt(params.page ?? '1', 10)
const caller = awaitcreateServerCaller()
// Server-side fetch — data is available before HTML is sentconst { items: posts, nextCursor } = await caller.post.list({
limit: 10,
})
return (
<div>
<h1>Posts</h1>
{/* Pass server-fetched data to a ClientComponent */}
<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'exportfunctionPostList({
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 stateconst { 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.tsximport { createServerCaller } from'@/lib/trpc/server-caller'import { UserProfile } from'@/components/user-profile'import { notFound } from'next/navigation'exportdefaultasyncfunctionUserPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const caller = awaitcreateServerCaller()
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 ClientHooks — useQuery, useMutation, Cache
// ============================================
'use client'import { trpc } from '@/lib/trpc/client'import { useState } from 'react'
// ---- useQuery: Fetch and cache data ----
// ReactQuery 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 for5 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 })
}}
>
ViewPost
</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}>RefreshAllUsers</button>
<button onClick={handleRefreshMe}>RefreshMyProfile</button>
<button onClick={handleResetCache}>ResetAllCache</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
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 instanceofZodError
? 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 inthrownewTRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in',
})
// Not found — resource does not existthrownewTRPCError({
code: 'NOT_FOUND',
message: 'Post not found',
})
// Bad request — invalid input (usually caught by Zod automatically)thrownewTRPCError({
code: 'BAD_REQUEST',
message: 'Page number must be positive',
})
// Forbidden — user is logged in but lacks permissionthrownewTRPCError({
code: 'FORBIDDEN',
message: 'You do not have permission to delete this post',
})
// Internal server error — unexpected failurethrownewTRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to process payment',
cause: error, // Original error — logged on server, not sent to client
})
// Rate limitedthrownewTRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded. Please try again in 60 seconds.',
})
// ---- Server: Error logging middleware ----// Log all errors for observabilityimport { middleware } from'../trpc'const errorLogger = middleware(async ({ path, type, next, ctx }) => {
const start = Date.now()
try {
const result = awaitnext()
const duration = Date.now() - start
// Log successful calls in developmentif (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 instanceofError ? error.message : 'Unknown error',
code: error instanceofTRPCError ? error.code : 'UNKNOWN',
userId: ctx.userId,
path,
type,
})
// Re-throw — tRPC handles the responsethrow error
}
})
// ---- Client: Error handling in hooks ----'use client'import { trpc } from'@/lib/trpc/client'exportfunctionPostEditor({ postId }: { postId: string }) {
const utils = trpc.useUtils()
const updatePost = trpc.post.update.useMutation({
onSuccess: () => {
utils.post.getById.invalidate({ id: postId })
utils.post.list.invalidate()
},
})
functionhandleSave(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>Youdo 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 asstring[]).join(', ')}
</li>
)
)}
</ul>
)}
</div>
)}
{!['UNAUTHORIZED', 'NOT_FOUND', 'FORBIDDEN', 'BAD_REQUEST'].includes(
updatePost.error.data?.code ?? ''
) && (
<p>An unexpected error occurred. Pleasetry again.</p>
)}
</div>
)}
<button onClick={() => handleSave('Title', 'Content')}>
Save
</button>
</div>
)
}
// ---- Client: Error boundary for queries ----exportfunctionPostPage({ 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.
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.
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.
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.
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
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
Feature
tRPC
REST (Next.js Route Handlers)
GraphQL
Server Actions
Type Safety
End-to-end inferred
Manual or codegen
Codegen required
Partial (input only)
Client DX
Autocomplete, no fetch
Manual fetch calls
Query strings
Direct function calls
Caching
React Query integration
Manual (fetch cache)
Apollo/urql cache
None built-in
Real-time
Subscriptions (WebSocket)
SSE or WebSocket
Subscriptions
Not supported
External API
trpc-openapi adapter
Native
Native
Not designed for external use
Bundle Size
~15KB client
~0KB (native fetch)
~30KB (Apollo)
~0KB (server only)
Learning Curve
Low (TypeScript)
Low
High
Low
Best For
Full-stack TypeScript apps
Public APIs, microservices
Complex data graphs
Simple mutations, forms
Key takeaways
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).
Q02 of 05SENIOR
What is the difference between calling a tRPC procedure in a Server Component vs a Client Component?
ANSWER
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.
Q03 of 05SENIOR
How do you handle errors in tRPC, and what should be exposed to the client?
ANSWER
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).
Q04 of 05SENIOR
Why should you split tRPC routers by domain, and what happens if you don't?
ANSWER
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.
Q05 of 05JUNIOR
What is the purpose of superjson in tRPC, and what happens without it?
ANSWER
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.
01
How does tRPC provide end-to-end type safety, and how is it different from REST or GraphQL?
SENIOR
02
What is the difference between calling a tRPC procedure in a Server Component vs a Client Component?
SENIOR
03
How do you handle errors in tRPC, and what should be exposed to the client?
SENIOR
04
Why should you split tRPC routers by domain, and what happens if you don't?
SENIOR
05
What is the purpose of superjson in tRPC, and what happens without it?
JUNIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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).
Was this helpful?
03
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.
Was this helpful?
04
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.
Was this helpful?
05
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.