webdev.complete
šŸ”— tRPC — End-to-End Type Safety
šŸš€Next.js & T3
Lesson 98 of 117
30 min

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.

server/trpc.ts
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.

superjson is almost always worth it
Plain JSON can't encode 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.

server/routers/post.ts
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 } });
    }),
});
server/routers/_app.ts
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.
ts
publicProcedure
  .input(z.object({ title: z.string().min(3) }))
  .mutation(async ({ ctx, input }) => {
    return ctx.db.post.create({ data: input });
  });
Side effects belong in mutations
Don't put writes inside a query. Queries get refetched on window focus, network reconnect, prefetch, hover, and more. A write inside one will run many times for one user action.

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).

ts
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.

server/context.ts
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.

server/trpc.ts
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&apos;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.

server/routers/post.ts
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
      });
    }),
});
Keep middlewares small
The whole point is composition. One middleware enforces auth, another enforces a role, another rate-limits. You compose them into procedure variants (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.

ts
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:

bash
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.ts

Quiz

Quiz1 / 3

Why prefer mutation() over query() for a write?

Recap

  • One initTRPC instance per app; export router, publicProcedure, middleware.
  • .query() for reads, .mutation()for writes. Don't mix.
  • Zod inside .input() validates at runtime and types at compile time.
  • ctx holds the database client, current session, and anything else procedures need.
  • Compose middleware into procedure variants like protectedProcedure for auth.
  • Throw TRPCError with standard codes for typed client-side error handling.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.