Dhirendra.
Back to Blog
TypeScriptNext.jstRPCAPI

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.

December 10, 20243 min read569 words

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.

// keep reading

Related Articles