Expert guidance for tRPC v11 type-safe APIs, procedures, middleware, error handling, and TanStack Query integration...
Expert guidance for tRPC v11 with type-safe APIs, procedures, middleware patterns, and TanStack React Query integration.
src/
├── trpc/
│ ├── trpc.ts # tRPC initialization (t.router, t.procedure)
│ ├── context.ts # Request context type
│ ├── router.ts # Root router (merges all sub-routers)
│ ├── users.ts # User procedures
│ └── posts.ts # Post procedures
├── lib/
│ └── trpc-client.ts # React client setup
└── server.ts # tRPC adapter integration
// src/trpc/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server"
import type { Context } from "./context"
const t = initTRPC.context<Context>().create()
export const router = t.router
export const procedure = t.procedure
export const publicProcedure = t.procedure
// src/trpc/context.ts
export interface Context {
user?: { id: string; role: "user" | "admin" }
db: DatabaseClient
}
export function createContext({ req }: { req: Request }): Context {
const token = req.headers.get("authorization")
return {
user: token ? verifyToken(token) : undefined,
db: getDbClient(),
}
}
// src/trpc/router.ts
import { router } from "./trpc"
import { usersRouter } from "./users"
import { postsRouter } from "./posts"
export const appRouter = router({
users: usersRouter,
posts: postsRouter,
})
export type AppRouter = typeof appRouter
import { z } from "zod"
import { router, publicProcedure } from "./trpc"
export const usersRouter = router({
// Simple query
list: publicProcedure.query(async ({ ctx }) => {
return ctx.db.users.findMany()
}),
// Query with input
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const user = await ctx.db.users.findUnique({ where: { id: input.id } })
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" })
}
return user
}),
})
export const postsRouter = router({
create: publicProcedure
.input(z.object({
title: z.string().min(1).max(100),
content: z.string(),
published: z.boolean().default(false),
}))
.mutation(async ({ ctx, input }) => {
return ctx.db.posts.create({ data: input })
}),
update: publicProcedure
.input(z.object({
id: z.string(),
title: z.string().optional(),
content: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
return ctx.db.posts.update({ where: { id }, data })
}),
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.posts.delete({ where: { id: input.id } })
return { success: true }
}),
})
import { observable } from "@trpc/server/observable"
export const eventsRouter = router({
onUpdate: publicProcedure
.input(z.object({ channelId: z.string() }))
.subscription(({ input }) => {
return observable<{ message: string }>((emit) => {
const handler = (data: { message: string }) => {
emit.next(data)
}
eventEmitter.on(input.channelId, handler)
return () => {
eventEmitter.off(input.channelId, handler)
}
})
}),
})
// src/trpc/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server"
const t = initTRPC.context<Context>().create()
// 1. Public procedure with logging
const loggedProcedure = t.procedure.use(async ({ path, type, next }) => {
const start = Date.now()
const result = await next()
console.log(`${type} ${path} - ${Date.now() - start}ms`)
return result
})
// 2. Authenticated procedure (extends logged)
export const authedProcedure = loggedProcedure.use(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" })
}
return next({
ctx: { ...ctx, user: ctx.user }, // user is now non-null
})
})
// 3. Admin procedure (extends authed)
export const adminProcedure = authedProcedure.use(async ({ ctx, next }) => {
if (ctx.user.role !== "admin") {
throw new TRPCError({ code: "FORBIDDEN", message: "Admin access required" })
}
return next({ ctx })
})
export const router = t.router
export const publicProcedure = loggedProcedure
import { router, authedProcedure, adminProcedure } from "./trpc"
export const adminRouter = router({
// Requires authentication
getProfile: authedProcedure.query(({ ctx }) => {
return ctx.db.users.findUnique({ where: { id: ctx.user.id } })
}),
// Requires admin role
deleteUser: adminProcedure
.input(z.object({ userId: z.string() }))
.mutation(({ ctx, input }) => {
return ctx.db.users.delete({ where: { id: input.userId } })
}),
})
// Add organization context
const withOrganization = t.middleware(async ({ ctx, input, next }) => {
const orgId = (input as { organizationId?: string })?.organizationId
if (!orgId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "organizationId required" })
}
const org = await ctx.db.organizations.findUnique({ where: { id: orgId } })
if (!org) {
throw new TRPCError({ code: "NOT_FOUND", message: "Organization not found" })
}
return next({
ctx: { ...ctx, organization: org },
})
})
export const orgProcedure = authedProcedure.use(withOrganization)
| Code | HTTP Status | Use Case |
|---|---|---|
BAD_REQUEST |
400 | Invalid input |
UNAUTHORIZED |
401 | Not logged in |
FORBIDDEN |
403 | Insufficient permissions |
NOT_FOUND |
404 | Resource doesn't exist |
CONFLICT |
409 | Resource conflict |
PRECONDITION_FAILED |
412 | Condition not met |
TOO_MANY_REQUESTS |
429 | Rate limited |
INTERNAL_SERVER_ERROR |
500 | Server error |
import { TRPCError } from "@trpc/server"
// Basic error
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
})
// With cause (preserves stack trace)
try {
await riskyOperation()
} catch (err) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Operation failed",
cause: err,
})
}
// In server setup
const handler = createHTTPHandler({
router: appRouter,
createContext,
onError: ({ error, type, path, input, ctx }) => {
console.error(`tRPC Error: ${type} ${path}`, {
code: error.code,
message: error.message,
input,
userId: ctx?.user?.id,
})
// Report to error tracking
if (error.code === "INTERNAL_SERVER_ERROR") {
reportError(error)
}
},
})
// src/lib/trpc-client.ts
import { createTRPCContext } from "@trpc/tanstack-react-query"
import { httpBatchLink } from "@trpc/client"
import type { AppRouter } from "@/trpc/router"
export const { TRPCProvider, useTRPC, useTRPCClient } = createTRPCContext<AppRouter>()
export function createTRPCClient() {
return {
links: [
httpBatchLink({
url: "/api/trpc",
headers: () => ({
authorization: getAuthToken(),
}),
}),
],
}
}
// src/main.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { TRPCProvider, createTRPCClient } from "@/lib/trpc-client"
const queryClient = new QueryClient()
const trpcClient = createTRPCClient()
function App() {
return (
<TRPCProvider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<Router />
</QueryClientProvider>
</TRPCProvider>
)
}
// src/lib/trpc-client.ts
import { createTRPCReact } from "@trpc/react-query"
import { httpBatchLink } from "@trpc/client"
import type { AppRouter } from "@/trpc/router"
export const trpc = createTRPCReact<AppRouter>()
export function createTRPCClient() {
return trpc.createClient({
links: [
httpBatchLink({
url: "/api/trpc",
}),
],
})
}
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useTRPC } from "@/lib/trpc-client"
function UserProfile({ userId }: { userId: string }) {
const trpc = useTRPC()
const queryClient = useQueryClient()
// Query
const { data: user, isLoading } = useQuery(
trpc.users.getById.queryOptions({ id: userId })
)
// Mutation with cache invalidation
const updateUser = useMutation({
...trpc.users.update.mutationOptions(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: trpc.users.getById.queryKey({ id: userId }) })
},
})
if (isLoading) return <div>Loading...</div>
return (
<div>
<h1>{user?.name}</h1>
<button onClick={() => updateUser.mutate({ id: userId, name: "New Name" })}>
Update
</button>
</div>
)
}
import { trpc } from "@/lib/trpc-client"
function UserProfile({ userId }: { userId: string }) {
const utils = trpc.useUtils()
// Query
const { data: user, isLoading } = trpc.users.getById.useQuery({ id: userId })
// Mutation
const updateUser = trpc.users.update.useMutation({
onSuccess: () => {
utils.users.getById.invalidate({ id: userId })
},
})
if (isLoading) return <div>Loading...</div>
return (
<div>
<h1>{user?.name}</h1>
<button onClick={() => updateUser.mutate({ id: userId, name: "New Name" })}>
Update
</button>
</div>
)
}
const updatePost = useMutation({
...trpc.posts.update.mutationOptions(),
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: trpc.posts.getById.queryKey({ id: newData.id }) })
const previous = queryClient.getQueryData(trpc.posts.getById.queryKey({ id: newData.id }))
queryClient.setQueryData(
trpc.posts.getById.queryKey({ id: newData.id }),
(old) => ({ ...old, ...newData })
)
return { previous }
},
onError: (err, newData, context) => {
queryClient.setQueryData(
trpc.posts.getById.queryKey({ id: newData.id }),
context?.previous
)
},
})
import { z } from "zod"
// Pagination
const paginationInput = z.object({
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(100).default(20),
})
// Filters
const filterInput = z.object({
search: z.string().optional(),
status: z.enum(["active", "inactive", "all"]).default("all"),
sortBy: z.enum(["createdAt", "name", "updatedAt"]).default("createdAt"),
sortOrder: z.enum(["asc", "desc"]).default("desc"),
})
// Combined
export const listUsersInput = paginationInput.merge(filterInput)
// Usage
list: publicProcedure
.input(listUsersInput)
.query(async ({ ctx, input }) => {
const { page, limit, search, status, sortBy, sortOrder } = input
// ...
}),
const createUserInput = z.object({
email: z.string().email(),
password: z.string().min(8).regex(/[A-Z]/, "Must contain uppercase"),
profile: z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
avatar: z.string().url().optional(),
}),
preferences: z.record(z.string(), z.unknown()).optional(),
})
// Client automatically batches requests made in same tick
const [users, posts, settings] = await Promise.all([
trpc.users.list.query(),
trpc.posts.list.query(),
trpc.settings.get.query(),
])
// → Single HTTP request with all 3 procedures
// Server
upload: publicProcedure
.input(z.object({
file: z.instanceof(File),
metadata: z.object({ name: z.string() }),
}))
.mutation(async ({ input }) => {
const buffer = await input.file.arrayBuffer()
// Process file...
}),
// Client - use FormData link
import { unstable_formDataLink } from "@trpc/client"
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"
import type { AppRouter } from "@/trpc/router"
type RouterInputs = inferRouterInputs<AppRouter>
type RouterOutputs = inferRouterOutputs<AppRouter>
// Usage
type CreateUserInput = RouterInputs["users"]["create"]
type User = RouterOutputs["users"]["getById"]
export type { AppRouter } to prevent server code in client bundlesinitTRPC once per app, not per router filehttpBatchLink batches requests automatically| v10 | v11 |
|---|---|
trpc.users.getById.useQuery() |
useQuery(trpc.users.getById.queryOptions()) |
trpc.users.create.useMutation() |
useMutation(trpc.users.create.mutationOptions()) |
utils.users.invalidate() |
queryClient.invalidateQueries({ queryKey: trpc.users.queryKey() }) |
| WebSocket subscriptions | SSE subscriptions (recommended) |