Routers, Procedures, Context
Queries, mutations, input validation, context, middleware.
A tRPC app is a tree of routers, each holding procedures. The wiring is simpler than it looks: one builder, a router function, and two flavors of procedure (query and mutation). Add Zod for input validation, a context object for things like the current user and the database client, and middleware to enforce auth. That's the whole shape of a tRPC backend.
initTRPC: one builder, exported once
You create a tRPC instance once per app, then export the helpers from it. Putting this in server/trpc.ts is the convention.
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
const t = initTRPC.context<Context>().create({
transformer: superjson, // sends Date, Map, BigInt over the wire
errorFormatter({ shape }) {
return shape;
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;Everything else imports router and publicProcedure from this file. The reason for a single instance is type identity: every procedure shares the same Context type, which makes the inferred types consistent across the tree.
Date, Map, Set, or BigInt. Superjson is the most common transformer, used by T3, and it's a one-line upgrade. Without it, your dates arrive on the client as strings.Routers: nest them like folders
A router is a record of procedures and child routers. You compose them into the appRouter, then export its type.
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
export const postRouter = router({
list: publicProcedure.query(async ({ ctx }) => {
return ctx.db.post.findMany({ orderBy: { createdAt: "desc" } });
}),
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.db.post.findUnique({ where: { id: input.id } });
}),
});import { router } from "../trpc";
import { postRouter } from "./post";
import { userRouter } from "./user";
export const appRouter = router({
post: postRouter,
user: userRouter,
});
export type AppRouter = typeof appRouter;From the client, that's now trpc.post.list, trpc.post.byId, trpc.user.byId, and so on.
query vs mutation
Two procedure flavors. They look identical except for one method call, but the framework treats them differently in caching and invalidation.
.query()is for reads. Idempotent, cacheable, retried automatically on focus or network reconnect by the React Query client. Use for: list, find, search, aggregate..mutation()is for writes. Not cached, not auto-retried, runs on demand. Use for: create, update, delete, sign-in.
publicProcedure
.input(z.object({ title: z.string().min(3) }))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({ data: input });
});Input validation with Zod
Procedures accept an input schema. The schema does two jobs in one: runtime parsing (throws on malformed input) and compile-time inference (the handler sees a fully typed input).
import { z } from "zod";
const CreateUser = z.object({
email: z.string().email(),
age: z.number().int().min(13),
bio: z.string().max(500).optional(),
});
publicProcedure
.input(CreateUser)
.mutation(async ({ input }) => {
// input is { email: string; age: number; bio?: string }
return createUser(input);
});Output validation (sometimes)
You can also pass an .output(...) schema. It verifies the procedure returns what you say it does. Useful for catching accidental leaks of database internals (passwords, tokens, etc.) before they hit the wire.
Context: session, db, request
Every procedure call receives a ctx object you build at request time. The classic shape is: database client, current session, request headers.
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { auth } from "@/server/auth";
import { db } from "@/server/db";
export async function createContext(opts: FetchCreateContextFnOptions) {
const session = await auth(); // null if not signed in
return {
db,
session,
headers: opts.req.headers,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;Notice the initTRPC.context<Context>()call from earlier. That's where this type plugs in. Now ctx.db and ctx.session are typed everywhere.
Middleware: cross-cutting logic
Middleware is the way to attach authorization, logging, rate limiting, or anything that should wrap many procedures. The most common use is a protectedProcedure.
import { TRPCError } from "@trpc/server";
const enforceUserIsAuthed = middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// narrow session to non-null so handlers don't have to check
session: { ...ctx.session, user: ctx.session.user },
},
});
});
export const protectedProcedure = publicProcedure.use(enforceUserIsAuthed);Now any procedure built off protectedProcedure is guaranteed a logged-in user inside ctx.session.user, and TypeScript reflects that.
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
export const postRouter = router({
list: publicProcedure.query(({ ctx }) => ctx.db.post.findMany()),
create: protectedProcedure
.input(z.object({ title: z.string().min(3) }))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
data: { ...input, authorId: ctx.session.user.id }, // session is narrowed
});
}),
});adminProcedure, rateLimitedProcedure) and use the variant on the relevant procedures.Errors that the client can read
Throw TRPCError with one of the standard codes (UNAUTHORIZED, FORBIDDEN, NOT_FOUND, BAD_REQUEST, etc.) and the client gets a typed error object back. The error includes the Zod issues if input validation failed, so client forms can map them straight to field-level messages.
import { TRPCError } from "@trpc/server";
protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const post = await ctx.db.post.findUnique({ where: { id: input.id } });
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
if (post.authorId !== ctx.session.user.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "Not your post" });
}
return ctx.db.post.delete({ where: { id: input.id } });
});Putting it together
Typical directory:
src/server/
āāā trpc.ts // initTRPC + publicProcedure + protectedProcedure
āāā context.ts // createContext()
āāā db.ts // Prisma/Drizzle client
āāā auth.ts // session helper
āāā routers/
āāā _app.ts // appRouter + AppRouter type
āāā post.ts
āāā user.tsQuiz
Why prefer mutation() over query() for a write?
Recap
- One
initTRPCinstance per app; exportrouter,publicProcedure,middleware. .query()for reads,.mutation()for writes. Don't mix.- Zod inside
.input()validates at runtime and types at compile time. ctxholds the database client, current session, and anything else procedures need.- Compose middleware into procedure variants like
protectedProcedurefor auth. - Throw
TRPCErrorwith standard codes for typed client-side error handling.