Building Type-Safe APIs with tRPC and Next.js App Router
How to set up end-to-end type safety in your full-stack app — no code generation, no runtime surprises. A practical guide with real patterns.
Why tRPC?
REST APIs have a fundamental problem: your backend and frontend can drift apart silently. You rename a field on the server, forget to update the client, and find out at runtime when a user hits the error. GraphQL solves this with a schema, but it brings code generation, a build step, and a mental model that's overkill for most apps.
tRPC takes a different approach. It uses TypeScript's type system itself as the contract between your server and client — no schema language, no code gen. If your backend changes, TypeScript tells you immediately, at the call site in your frontend code.
Setting Up the Router
Start by installing the dependencies:
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
Create your tRPC instance and a base router:
// lib/trpc/init.ts
import { initTRPC } from "@trpc/server";
import { ZodError } from "zod";
const t = initTRPC.create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
Defining Procedures
Procedures are your API endpoints. Each one has an input validator (Zod), optional middleware, and a resolver function:
// server/routers/posts.ts
import { z } from "zod";
import { router, publicProcedure } from "@/lib/trpc/init";
import { db } from "@/lib/db";
export const postsRouter = router({
list: publicProcedure
.input(z.object({ limit: z.number().min(1).max(100).default(10) }))
.query(async ({ input }) => {
return db.post.findMany({ take: input.limit, orderBy: { createdAt: "desc" } });
}),
byId: publicProcedure
.input(z.object({ id: z.string().cuid() }))
.query(async ({ input }) => {
const post = await db.post.findUnique({ where: { id: input.id } });
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
create: publicProcedure
.input(z.object({ title: z.string().min(1), content: z.string() }))
.mutation(async ({ input }) => {
return db.post.create({ data: input });
}),
});
Wiring Into App Router
tRPC needs an HTTP handler in your Next.js app. With App Router, put it at app/api/trpc/[trpc]/route.ts:
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/root";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
Using It in Client Components
On the client, queries feel like any React Query hook — but fully typed:
// No type imports needed — types flow automatically
const { data: posts } = trpc.posts.list.useQuery({ limit: 5 });
const createPost = trpc.posts.create.useMutation();
// TypeScript knows the exact shape of `posts` and `createPost.mutate`
The Key Insight
The reason this works is that tRPC never sends types over the wire. Types only exist at compile time. The appRouter type is imported directly into your client-side tRPC setup, and TypeScript resolves everything from there. At runtime, it's just regular HTTP JSON — no magic.
This means you get the DX of GraphQL (typed everything, autocomplete, safe refactoring) with the simplicity of REST (no schema, no codegen, no extra tooling).
When Not to Use tRPC
tRPC is ideal for full-stack TypeScript monorepos. It's not the right choice if your API needs to be consumed by non-TypeScript clients, if you're building a public API, or if your team has strong REST conventions. For those cases, stick with REST + OpenAPI spec generation.
For everything else — particularly internal Next.js apps — tRPC is hard to beat.
Related Articles
Optimistic UI Patterns That Actually Work
Real-world patterns for building fast, predictable UIs with Zustand and React Query — and how to handle rollbacks gracefully when things go wrong.