42.3 tRPC with Next.js: API Routes and the Client
Right, so you’ve decided to build something that doesn’t make you want to tear your hair out. Good choice. tRPC with Next.js is essentially that: a way to have your frontend and backend hold hands so tightly that you can’t mess up the data between them. It’s end-to-end typesafety, and once you use it, you’ll feel like you’ve been coding with one hand tied behind your back your whole life. We’re going to set this up using Next.js’s API Routes, because they’re simple, they’re there, and they work.
First, the mental model: forget “REST” for a second. You’re not making a /api/users endpoint that might return a user, or an error, or a cat picture if someone messed up the route. With tRPC, you’re defining procedures—functions that live on the server that your frontend can call directly, with their arguments and return types known at compile time. It’s RPC, but without the “oh god, how do I type this” panic.
The tRPC Router: Your Single Source of Truth
Everything in tRPC starts with the router. This is where you define every procedure your client can call. You’ll organize these into smaller sub-routers for maintainability. Let’s create our main router. Stick this in something like server/trpc.ts.
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
// Initialize tRPC. This is where you'd add context (like authentication)
// but for now, we'll keep it simple.
const t = initTRPC.create();
// A simple sub-router for user-related procedures.
export const userRouter = t.router({
getById: t.procedure
.input(z.object({ id: z.string() })) // Validate input with Zod
.query(async ({ input }) => {
// This is where you'd fetch from your database.
// For now, we'll fake it.
const user = { id: input.id, name: 'Bilbo Baggins' };
if (!user) {
throw new Error('User not found'); // tRPC handles errors gracefully
}
return user; // TypeScript infers the return type as { id: string; name: string }
}),
create: t.procedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ input }) => {
// Here you'd create the user in the DB.
const newUser = { id: String(Math.random()), name: input.name };
return newUser; // Return the created user
}),
});
// This is your app's main router. Merge all your sub-routers here.
export const appRouter = t.router({
user: userRouter, // Procedures are now accessible under `user.*`
// post: postRouter, // You'd add more routers here
});
// These are the important types you'll need on the frontend.
export type AppRouter = typeof appRouter;
Why Zod? Because tRPC is philosophically opposed to you receiving untrusted data. Zod validates the incoming request at runtime, ensuring the types you think you’re getting are the types you’re actually getting. It’s the bouncer at the type club.
The Next.js API Route: The Server-Side Gatekeeper
Next.js API Routes are just functions that handle HTTP requests. We’ll create one that becomes the HTTP gateway for all tRPC requests. The @trpc/server/adapters/next package gives us a handy adapter to do the heavy lifting. Create pages/api/trpc/[trpc].ts.
// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/trpc'; // Import your main router
import { createContext } from '../../../server/context'; // Optional: for authentication
// Export the Next.js API handler.
// The `[trpc]` in the filename means requests to `/api/trpc/<procedure>`
// get routed here automatically.
export default createNextApiHandler({
router: appRouter,
createContext, // We'll talk about this in a second. For now, omit it.
// onError: ({ error }) => { ... } // Great for custom error logging
});
And that’s it. Seriously. This one file handles every tRPC request. The adapter takes care of parsing the request, calling the correct procedure on your router, and sending back a nicely formatted response. It’s shockingly elegant, a rarity in web dev.
The tRPC Client: Your Typesafe Window to the Server
Now for the magic on the frontend. We need to create a tRPC client that knows the types of our AppRouter. We’ll use React Query under the hood, which means we get caching, invalidation, and all that good stuff for free.
First, let’s set up the provider in pages/_app.tsx. This makes the client available throughout our app.
// pages/_app.tsx
import type { AppType } from 'next/app';
import { trpc } from '../utils/trpc';
const MyApp: AppType = ({ Component, pageProps }) => {
return <Component {...pageProps} />;
};
// Wrap the app with the tRPC Provider. This is where the connection is made.
export default trpc.withTRPC(MyApp);
But what’s ../utils/trpc? That’s our client configuration. This is a crucial file.
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/trpc';
// This is a strongly-typed React hook that knows all your procedures.
export const trpc = createTRPCReact<AppRouter>();
// We'll also set up the vanilla client for use outside React components.
import { createTRPCClient } from '@trpc/client';
import { httpBatchLink } from '@trpc/client/links/httpBatchLink';
export const vanillaClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc', // Your API URL
}),
],
});
Using Hooks in Your Components: This is Where It Pays Off
Now you can use your procedures in components with full autocomplete and type checking. It feels like cheating.
// components/UserProfile.tsx
import { trpc } from '../utils/trpc';
export const UserProfile = ({ userId }: { userId: string }) => {
// `useQuery` for GET-like operations. Autocomplete knows `user.getById` exists.
// The type of `data` is inferred as { id: string; name: string } | undefined.
const { data: user, isLoading } = trpc.user.getById.useQuery({ id: userId });
// `useMutation` for POST-like operations.
const createUser = trpc.user.create.useMutation();
if (isLoading) return <div>Loading...</div>;
if (!user) return <div>Not found</div>;
return (
<div>
<h1>Hello {user.name}</h1>
<button
onClick={() => {
createUser.mutate({ name: 'New User' }); // Try typing a number here. I dare you.
}}
>
Create User
</button>
</div>
);
};
The beauty here is absolute certainty. The useQuery hook knows it’s calling user.getById and that it requires an object with an id: string property. The return type is perfectly known. Your linter will scream at you if you get it wrong. You’ve effectively eliminated an entire category of bugs.
The Critical Pitfall: Context for Authentication
I glossed over the createContext function earlier. This is the most common stumbling block. The context is data that is available in every procedure, like the authenticated user. If you don’t set this up, you have no way to do auth. Here’s the minimal version.
// server/context.ts
import * as trpc from '@trpc/server';
import { NextApiRequest } from 'next';
// This function runs on every request. Use it to populate the context.
export async function createContext({
req,
}: {
req: NextApiRequest;
}) {
// For example, get the user from the request cookies or headers.
const authHeader = req.headers.authorization;
// ... your auth logic here ...
return {
user: null, // or the user object you extracted
};
}
export type Context = trpc.inferAsyncReturnType<typeof createContext>;
You must then pass this type to your initTRPC call:
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { Context } from './context'; // Import the context type
const t = initTRPC.context<Context>().create(); // Tell tRPC about it
// Now in your procedures, the `ctx` argument is typed!
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) {
throw new trpc.TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, user: ctx.user } }); // user is now non-null in downstream procedures
});
Forgetting to set up context is the number one reason people get stuck. It’s the first thing you should do after getting the basic “hello world” working. This setup turns tRPC from a neat trick into the robust backbone of your entire application.