| topic | trpc | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| last_verified | 2026-06-27 | |||||||||||
| sources |
|
tRPC v11 with React Query v5, wired into Next.js App Router.
"@trpc/server": "^11.18.0",
"@trpc/client": "^11.18.0",
"@trpc/react-query": "^11.18.0",
"@tanstack/react-query": "^5.101.1",
"zod": "^4.4.3"export interface TRPCContext {
req: NextRequest
session: Session | null
}
export async function createTRPCContext({ req }: { req: NextRequest }): Promise<TRPCContext>createTRPCContext calls auth() from NextAuth (@/auth) to resolve the current session, then returns { req, session }.
publicProcedure — alias for t.procedure. No auth check.
protectedProcedure — middleware runs before the handler:
- Checks
ctx.session?.user. - Throws
TRPCError({ code: 'UNAUTHORIZED' })ifsessionisnullorsession.useris absent.
createCallerFactory — exported from t.createCallerFactory; used by lib/trpc/server.ts to build server-side callers.
export const appRouter = router({
health: healthRouter,
auth: authRouter,
notifications: notificationsRouter,
})
export type AppRouter = typeof appRouterAppRouter is the single type exported to the client.
Uses publicProcedure. Fetches GET ${BACKEND_URL}/health (defaults to http://localhost:8080) and returns { status: string; database: string }. Throws a plain Error if the backend responds with a non-2xx status.
Uses protectedProcedure.
session— query, returns{ authenticated: true, user: ctx.session?.user ?? null }.signOut— mutation, returns{ success: true }.
Uses protectedProcedure with Zod input validation. Current stubs:
registerFcmToken— mutation, input{ token: z.string().min(1) }, returns{ registered: true, token }.list— query, returns[].
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createTRPCContext({ req }),
})
export { handler as GET, handler as POST }All tRPC requests (batch GET and mutation POST) hit /api/trpc/[trpc].
export const trpc = createTRPCReact<AppRouter>()trpc is the typed client used in Client Components.
- In the browser: returns
''(relative URL, same origin). - On Vercel: returns
https://${process.env.VERCEL_URL}. - Elsewhere (local server-side): returns
'http://localhost:3000'.
export function TRPCProvider({ children }: { children: React.ReactNode })Creates a QueryClient and a trpc HTTP batch link client, both memoized in useState. Wraps children with trpc.Provider and QueryClientProvider. This is a 'use client' component.
export const createServerCaller = cache(async () => {
const headerList = await headers()
const req = new Request('http://internal', { headers: headerList }) as NextRequest
const ctx = await createTRPCContext({ req })
return createCaller(ctx)
})createServerCaller is wrapped in React's cache() so it is deduplicated per request. It forwards the incoming request headers (including Cookie and Authorization) to the context, which means protectedProcedure checks work for server-rendered pages.
Usage in a Server Component:
import { createServerCaller } from '@/lib/trpc/server'
export default async function HealthPage() {
const caller = await createServerCaller()
const health = await caller.health.query()
return <p>Backend status: {health.status}</p>
}app/providers.tsx is a thin 'use client' wrapper. From outermost to innermost: NuqsAdapter (URL search-param state), SessionProvider (next-auth session), TRPCProvider (React Query + tRPC). A Toaster is rendered as a sibling of TRPCProvider inside SessionProvider:
'use client'
import { TRPCProvider } from '@/lib/trpc/client'
import { SessionProvider } from 'next-auth/react'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { Toaster } from '@/components/ui/sonner'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<NuqsAdapter>
<SessionProvider>
<TRPCProvider>{children}</TRPCProvider>
<Toaster richColors position="top-right" />
</SessionProvider>
</NuqsAdapter>
)
}app/layout.tsx wraps {children} with <Providers> inside <body>:
<body className="min-h-full flex flex-col">
<Providers>{children}</Providers>
</body>// No 'use client' — runs on the server
import { createServerCaller } from '@/lib/trpc/server'
export default async function StatusPage() {
const caller = await createServerCaller()
const health = await caller.health.query()
return <p>{health.status}</p>
}'use client'
import { trpc } from '@/lib/trpc/client'
export function HealthWidget() {
const { data, isLoading, isError } = trpc.health.query.useQuery()
if (isLoading) return <p>Loading…</p>
if (isError) return <p>Unavailable</p>
return <p>Status: {data.status} / DB: {data.database}</p>
}'use client'
import { trpc } from '@/lib/trpc/client'
export function SignOutButton() {
const signOut = trpc.auth.signOut.useMutation()
return (
<button onClick={() => signOut.mutate()} disabled={signOut.isPending}>
Sign out
</button>
)
}- Create
server/routers/<feature>.tsand export arouter({})built frompublicProcedureorprotectedProcedure. - Import it in
server/routers/_app.tsand add it toappRouter. - The new procedures are immediately available to
trpc.<feature>.*in Client Components andcaller.<feature>.*in Server Components.
// server/routers/posts.ts
import { publicProcedure, router } from '../trpc'
export const postsRouter = router({
list: publicProcedure.query(async () => {
return []
}),
})// server/routers/_app.ts
import { postsRouter } from './posts'
export const appRouter = router({
health: healthRouter,
auth: authRouter,
notifications: notificationsRouter,
posts: postsRouter, // add here
})Procedures are tested directly via appRouter.createCaller(ctx) — no HTTP server needed.
createTRPCContext calls auth() from NextAuth, so every test file that calls createTRPCContext must mock @/auth:
vi.mock('@/auth', () => ({
auth: vi.fn(),
}))
import { auth } from '@/auth'
const mockAuth = vi.mocked(auth)Pattern from server/routers/__tests__/auth.test.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { appRouter } from '../_app'
import { createTRPCContext } from '../../trpc'
import type { Session } from 'next-auth'
vi.mock('@/auth', () => ({ auth: vi.fn() }))
import { auth } from '@/auth'
const mockAuth = vi.mocked(auth)
const validSession: Session = {
user: { id: '123', email: 'test@example.com', name: 'Test User' },
expires: '2099-01-01T00:00:00.000Z',
}
function makeContext(session: Session | null = null) {
const req = new Request('http://localhost/api/trpc') as NextRequest
mockAuth.mockResolvedValue(session)
return createTRPCContext({ req })
}
it('throws UNAUTHORIZED when no session present', async () => {
const ctx = await makeContext(null)
const caller = appRouter.createCaller(ctx)
await expect(caller.auth.session()).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
})
it('allows access with valid session', async () => {
const ctx = await makeContext(validSession)
const caller = appRouter.createCaller(ctx)
const result = await caller.auth.session()
expect(result).toMatchObject({ authenticated: true, user: { email: 'test@example.com' } })
})For procedures that call fetch, stub it with vi.stubGlobal('fetch', mockFetch) before the test suite.