I spent two days last year fighting tRPC v10 with the Next.js App Router. Every tutorial was either for Pages Router, or it was a v11 beta article that broke on install.
Now that v11 is stable, the integration is genuinely good. Here's exactly how I set it up — no ceremony, just the patterns that work.
Why tRPC Still Makes Sense in 2026
Server Actions solved a lot. But they're one-way: client calls server, server returns. tRPC gives you a proper API layer with:
- Full TypeScript inference end-to-end (input → output, no casting)
- Procedures that work from Server Components, Client Components, and Server Actions
- Subscriptions via WebSockets when you need them
- Middleware for auth, rate limiting, logging — all typed
If your app has >10 API endpoints and you're maintaining them manually with fetch calls and Zod schemas on both ends, tRPC pays for itself in a week.
Installation
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next zod
npm install @tanstack/react-query
v11 ships with first-class App Router support. No adapter hacks needed.
The Router
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { getServerSession } from 'next-auth'
import { z } from 'zod'
const t = initTRPC.context<{
session: Awaited<ReturnType<typeof getServerSession>> | null
}>().create()
export const router = t.router
export const publicProcedure = t.procedure
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({ ctx: { session: ctx.session } })
})
// server/routers/posts.ts
import { router, protectedProcedure, publicProcedure } from '../trpc'
import { z } from 'zod'
export const postsRouter = router({
list: publicProcedure
.input(z.object({ cursor: z.string().optional(), limit: z.number().min(1).max(50).default(20) }))
.query(async ({ input }) => {
const posts = await db.post.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
})
const nextCursor = posts.length > input.limit ? posts.pop()!.id : undefined
return { posts, nextCursor }
}),
create: protectedProcedure
.input(z.object({ title: z.string().min(1).max(200), content: z.string() }))
.mutation(async ({ input, ctx }) => {
return db.post.create({
data: { ...input, authorId: ctx.session.user.id },
})
}),
})
// server/root.ts
import { router } from './trpc'
import { postsRouter } from './routers/posts'
export const appRouter = router({
posts: postsRouter,
})
export type AppRouter = typeof appRouter
The App Router Handler
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/root'
import { getServerSession } from 'next-auth'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: async () => ({
session: await getServerSession(),
}),
onError: ({ error }) => {
if (error.code === 'INTERNAL_SERVER_ERROR') {
console.error('tRPC error:', error)
}
},
})
export { handler as GET, handler as POST }
The Client Provider
// app/_providers/trpc.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createTRPCReact } from '@trpc/react-query'
import { httpBatchLink } from '@trpc/client'
import type { AppRouter } from '@/server/root'
import { useState } from 'react'
export const trpc = createTRPCReact<AppRouter>()
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: { queries: { staleTime: 60 * 1000 } },
}))
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
headers: () => ({ 'x-trpc-source': 'react' }),
}),
],
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
)
}
Using It From a Server Component (v11 Feature)
This is the pattern that wasn't possible in v10. You can call tRPC procedures directly in Server Components without an HTTP round-trip:
// app/posts/page.tsx
import { createCaller } from '@/server/root'
import { getServerSession } from 'next-auth'
export default async function PostsPage() {
const session = await getServerSession()
const caller = createCaller({ session })
// Direct call — no HTTP, full type safety
const { posts } = await caller.posts.list({ limit: 20 })
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
</article>
))}
</div>
)
}
The createCaller utility runs your router directly in the server process — same auth context, same middleware, zero HTTP overhead.
Using It From a Client Component
// components/CreatePostForm.tsx
'use client'
import { trpc } from '@/app/_providers/trpc'
import { useState } from 'react'
export function CreatePostForm() {
const [title, setTitle] = useState('')
const utils = trpc.useUtils()
const createPost = trpc.posts.create.useMutation({
onSuccess: () => {
utils.posts.list.invalidate()
setTitle('')
},
})
return (
<form onSubmit={e => {
e.preventDefault()
createPost.mutate({ title, content: '' })
}}>
<input value={title} onChange={e => setTitle(e.target.value)} />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create'}
</button>
</form>
)
}
Optimistic Updates
tRPC's useUtils() hook gives you typed access to the query cache. Optimistic updates are just cache manipulation:
const utils = trpc.useUtils()
const deletePost = trpc.posts.delete.useMutation({
onMutate: async ({ id }) => {
await utils.posts.list.cancel()
const prev = utils.posts.list.getData()
utils.posts.list.setData(undefined, old => ({
...old!,
posts: old!.posts.filter(p => p.id !== id),
}))
return { prev }
},
onError: (_err, _vars, ctx) => {
utils.posts.list.setData(undefined, ctx?.prev)
},
onSettled: () => {
utils.posts.list.invalidate()
},
})
The key thing here: setData is typed against your router's output schema. You can't accidentally set the wrong shape.
Error Handling
tRPC maps its error codes to HTTP status codes automatically, but you can also handle them specifically on the client:
createPost.mutate({ title }, {
onError: (error) => {
if (error.data?.code === 'UNAUTHORIZED') {
router.push('/login')
} else if (error.data?.code === 'BAD_REQUEST') {
// Zod validation error — error.data.zodError has field-level details
setErrors(error.data.zodError.fieldErrors)
}
}
})
What I'd Skip
tRPC subscriptions via WebSockets — unless you specifically need real-time push, just use polling or Supabase Realtime. The WebSocket setup adds infra complexity that most apps don't need.
tRPC with Server Actions — you can do this, but at that point you're mixing two patterns. Pick one. I use tRPC for all data fetching and mutations, or I use plain Server Actions with Zod — not both.
The Bottom Line
tRPC v11 with App Router is the closest thing to a "solved" full-stack TypeScript setup I've found. The createCaller pattern for Server Components eliminates the biggest pain point from v10, and the rest of the API is clean enough that you stop thinking about the tooling and focus on the product.
If you're starting a new Next.js project today and TypeScript is non-negotiable, this is the stack.
Building with tRPC + Claude Code at whoffagents.com