42.2 Defining Procedures and Routers in tRPC
Right, let’s get our hands dirty. You’ve set up your tRPC context and type definitions, which means you’ve built the foundation and the plumbing. Now we get to the good part: actually building the procedures that will shuttle data between your frontend and backend. This is where you stop configuring and start doing.
Think of a tRPC router as a neatly organized toolbox. Each procedure is a specific tool—a hammer, a screwdriver, a weirdly shaped wrench you’ll use once and forget about. Our job is to forge these tools and arrange them so you can find exactly what you need without dumping the entire box on the floor.
The Basic Building Block: Defining a Procedure
Every single thing you do in tRPC starts with a procedure. It’s just a function with superpowers. The simplest form is a query, which is tRPC’s way of saying “GET me some data.” Let’s define one. We’ll use a publicProcedure, which is the default, unauthenticated tool you get out of the box.
// server/routers/post.router.ts
import { publicProcedure, router } from '../trpc';
const postRouter = router({
getById: publicProcedure
.input(z.number())
.query(async ({ input, ctx }) => {
// `input` is validated as a number thanks to Zod
const postId = input;
// `ctx` is our context we defined earlier, giving us access to our database
const post = await ctx.prisma.post.findUnique({
where: { id: postId },
});
if (!post) {
throw new Error(`Post with ID ${postId} not found`);
}
return post;
}),
});
See what happened there? We defined the shape of the input (a number) using Zod. This is non-optional. tRPC uses this to validate the request before your function even runs. This means you can ditch all those tedious if (!number) throw... checks at the start of your function. The validation is handled for you, and you can just write your business logic. It’s like having a bouncer for your API endpoint.
Mutations: For When You Want to Change Things
Queries are for fetching, but if you want to create, update, or delete data, you need a mutation. The structure is almost identical, which is the beauty of it. The mental model is the same.
// In the same postRouter
create: publicProcedure
.input(
z.object({
title: z.string().min(1, "Title is required, don't be lazy."),
content: z.string().min(1),
})
)
.mutation(async ({ input, ctx }) => {
// The input is now a validated object with `title` and `content`
const newPost = await ctx.prisma.post.create({
data: {
title: input.title,
content: input.content,
authorId: ctx.session?.user.id, // Example of using auth context
},
});
return newPost;
}),
The key difference is .mutation instead of .query. This tells tRPC (and the HTTP adapter underneath) that this procedure should be called with a POST request. Everything else—the input validation, the context, the error handling—works exactly the same. This consistency is what makes tRPC such a joy to use; you learn one pattern and apply it everywhere.
Composing Routers: Your Project’s Org Chart
You wouldn’t put all your company’s employees in one giant room. You organize them into teams. Do the same with your routers. A single file with 50 procedures is a nightmare. Instead, you create a router for each resource (postRouter, userRouter) and then merge them into a single, app-wide router.
// server/routers/_app.ts
import { router } from '../trpc';
import { postRouter } from './post.router';
import { userRouter } from './user.router';
export const appRouter = router({
post: postRouter,
user: userRouter,
// ...other routers
});
// This is the type you'll import on the frontend. It's your entire API's type signature!
export type AppRouter = typeof appRouter;
This structure is mirrored on the frontend. Your tRPC client will now have trpc.post.getById.useQuery() and trpc.user.update.useMutation(). It’s beautifully discoverable and keeps your codebase sane as it scales. This isn’t just a best practice; it’s the entire point of the system.
Error Handling: Not All Gloom, But Some Doom
You saw me throw new Error in the first example. That’s the simplest way, but it’s a bit rude. The client will just get a generic “INTERNAL_SERVER_ERROR.” You can do better. tRPC has a built-in TRPCError class for throwing structured, typed errors.
import { TRPCError } from '@trpc/server';
// Inside a procedure
const user = await ctx.prisma.user.findUnique({ where: { id: input.userId } });
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found. Did they vanish into the ether?',
});
}
if (user.role !== 'ADMIN') {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Nice try. You need an admin badge for this one.',
});
}
The code field is key. It’s a union type ('BAD_REQUEST' | 'UNAUTHORIZED' | ...) so you can handle different error types systematically on the frontend. This is infinitely better than parsing string messages. Use it. Your future self, trying to debug why a button is disabled, will thank you.
Middleware: The Bouncer and The Butler
Sometimes you need to run logic before a procedure. Maybe you need to ensure a user is authenticated for an entire group of procedures. Writing an auth check in every single one is a recipe for boredom and bugs. Enter middleware.
// server/trpc.ts (where you define your helpers)
import { middleware } from './trpc';
const isAdmin = middleware(async ({ ctx, next }) => {
if (ctx.session?.user.role !== 'ADMIN') {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
// This cleverly narrows the context type for subsequent procedures
session: { ...ctx.session, user: ctx.session.user },
},
});
});
// Then you create a protected procedure
export const adminProcedure = publicProcedure.use(isAdmin);
Now you can use adminProcedure anywhere, and it will automatically enforce the admin check. The magic is in the return next({ ctx }) pattern. This allows you to augment the context, adding new, more specific properties that are available to the final procedure. It’s how you go from a generic publicProcedure to an adminProcedure that knows for sure a user is present and is an admin. This pattern is how you build robust, secure APIs without repeating yourself.