tRPC Cheat Sheet
End-to-end typesafe API syntax for defining routers, procedures, middleware, and consuming them from a React client without codegen.
Defining a Router
Build type-safe procedures with Zod input validation on the server.
import { initTRPC } from '@trpc/server'import { z } from 'zod'const t = initTRPC.create()export const appRouter = t.router({ getUser: t.procedure .input(z.object({ id: z.string() })) .query(async ({ input }) => { return db.user.findUnique({ where: { id: input.id } }) }), createPost: t.procedure .input(z.object({ title: z.string().min(1), body: z.string() })) .mutation(async ({ input }) => { return db.post.create({ data: input }) }),})export type AppRouter = typeof appRouter
Context & Middleware
Attach auth/session data to context and gate procedures with protectedProcedure.
export const createContext = async ({ req }: { req: Request }) => { const session = await getSession(req) return { session }}const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) throw new TRPCError({ code: 'UNAUTHORIZED' }) return next({ ctx: { session: ctx.session } })})export const protectedProcedure = t.procedure.use(isAuthed)// usageconst meRouter = t.router({ me: protectedProcedure.query(({ ctx }) => ctx.session.user),})
React Query Integration
Consume procedures with fully-typed hooks, no manual fetch/types needed.
import { createTRPCReact } from '@trpc/react-query'import type { AppRouter } from '../server/router'export const trpc = createTRPCReact<AppRouter>()function UserProfile({ id }: { id: string }) { const { data, isLoading } = trpc.getUser.useQuery({ id }) const createPost = trpc.createPost.useMutation() if (isLoading) return <p>Loading...</p> return ( <button onClick={() => createPost.mutate({ title: 'Hi', body: '...' })}> {data?.name} </button> )}
HTTP Adapter (Next.js Route Handler)
Expose the router over HTTP with the fetch adapter.
// app/api/trpc/[trpc]/route.tsimport { fetchRequestHandler } from '@trpc/server/adapters/fetch'import { appRouter } from '@/server/router'import { createContext } from '@/server/context'const handler = (req: Request) => fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext, })export { handler as GET, handler as POST }
Core Concepts
Building blocks you compose to make a router.
- t.procedure- base builder for a query or mutation, chain .input()/.use()/.query()/.mutation()
- .query() vs .mutation()- query = read (GET-like, cached), mutation = write (POST-like, invalidates cache)
- t.router({...})- groups procedures into a namespaced, nestable API surface
- t.middleware()- wraps procedures to inject/validate context (auth, logging, rate limits)
- useUtils().invalidate()- React Query client helper to refetch after a mutation
- superjson- common transformer to serialize Dates/Maps/Sets across the wire
Subscriptions over WebSockets
Push real-time updates to clients using an observable-returning procedure and the WebSocket link.
// serverimport { observable } from '@trpc/server/observable'export const appRouter = t.router({ onMessage: t.procedure.subscription(() => { return observable<{ text: string }>((emit) => { const onMsg = (text: string) => emit.next({ text }) ee.on('message', onMsg) return () => ee.off('message', onMsg) }) }),})// client setupimport { createWSClient, wsLink } from '@trpc/client'const wsClient = createWSClient({ url: 'ws://localhost:3001' })const client = trpc.createClient({ links: [wsLink({ client: wsClient })] })
Custom Error Formatting
Flatten Zod validation errors and attach custom fields to every error response the client receives.
export const t = initTRPC.create({ errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, } },})// client: err.data?.zodError?.fieldErrors.title
Links: Batching & Splitting Requests
Combine multiple queries into one HTTP round trip, or route subscriptions to a different transport.
import { httpBatchLink, splitLink, wsLink } from '@trpc/client'const client = trpc.createClient({ links: [ splitLink({ condition: (op) => op.type === 'subscription', true: wsLink({ client: wsClient }), false: httpBatchLink({ url: '/api/trpc', maxURLLength: 2083 }), }), ],})// on the server, batching is handled automatically by the fetch adapter
Merging Sub-Routers
Compose a large API from feature-scoped routers instead of one flat file.
const userRouter = t.router({ get: t.procedure.query(getUser) })const postRouter = t.router({ list: t.procedure.query(listPosts) })export const appRouter = t.router({ user: userRouter, post: postRouter,})// client: trpc.user.get.useQuery(), trpc.post.list.useQuery()
Advanced Concepts
Patterns you reach for once a router grows beyond a toy example.
- server-side prefetch + hydration- call helpers.getUser.prefetch() in an RSC and hydrate the query cache to avoid a client waterfall
- output validators- .output(z.object(...)) strips fields not in the schema before they reach the client
- t.procedure.meta({...})- attach static metadata (e.g. rate-limit tier, OpenAPI info) readable in middleware
- createCallerFactory- call procedures directly server-side (e.g. in a cron job) without an HTTP round trip
- TRPCError codes- map to HTTP statuses (UNAUTHORIZED→401, NOT_FOUND→404, BAD_REQUEST→400) automatically
- transformer (superjson)- must be set identically on both t.router() init and the client link, or dates deserialize as strings
Never import server-only code (db clients, secrets) into a file that's also imported by the client bundle — because tRPC's type-safety works via TypeScript inference on the `AppRouter` type only, it's easy to accidentally leak a real import and blow up your client bundle size.