API

Learn how to create end-to-end type-safe APIs with tRPC.

tRPC (TypeScript Remote Procedure Call) creates end-to-end type-safe APIs. Combined with Tanstack Query, tRPC provides a seamless developer experience with autocompletion and automatic type inference. This also means better AI-assisted development.

Why tRPC?

  1. Automatic Type Safety — Types are written once on the server and automatically inferred on the client.
  2. Autocompletion — Full IntelliSense for available routes, arguments, and responses.
  3. Isolated Server Logic — Server logic is in a dedicated directory, keeping it separate from your frontend code.
  4. Framework Agnostic — If you move your backend to another framework, your API routes come with you.
  5. Scalable Structure — Clean three-layer architecture promotes code reusability and separation of concerns.

Vocabulary

TermDescription
ProcedureAPI endpoint — can be a query or mutation.
QueryProcedure that reads data.
MutationProcedure that creates, updates, or deletes data.
RouterCollection of procedures under a shared namespace.
ContextData every procedure can access (session, database client).
MiddlewareFunction that run code before and after a procedure. Can modify context.
Validation"Does this input data contain the right stuff?"

Architecture

The API follows a three-layer pattern: Router → Service → Repository.

billing.router.ts
billing.service.ts
billing.repository.ts
billing.input.ts
  • Router: Thin controller layer that maps procedures to service methods.
  • Service: Business logic, feature flag checks, error handling.
  • Repository: Pure Prisma queries with explicit field selection.
  • Input: Zod schemas for procedure input validation.

Protecting API Routes

Three procedure types are pre-configured:

packages/api/src/trpc.ts
/**
 * Public procedure
 * No authentication is required.
 */
export const publicProcedure = t.procedure;

/**
 * Protected procedure
 * Requires an authenticated session.
 */
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  const user = ctx.session?.user;
  if (!user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({
    ctx: {
      session: {
        ...ctx.session,
        user,
      },
    },
  });
});

/**
 * Admin procedure
 * Requires an authenticated session with admin role.
 */
export const adminProcedure = t.procedure.use(async ({ ctx, next }) => {
  const user = ctx.session?.user;
  if (!user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  if (user.role !== Role.ADMIN) {
    throw new TRPCError({ code: "FORBIDDEN" });
  }
  // Confirm the user has the admin role in the database to prevent role hijacking
  const isAdmin = await userService.isAdmin(user.id);
  if (!isAdmin) {
    throw new TRPCError({ code: "FORBIDDEN" });
  }
  return next({
    ctx: {
      session: {
        ...ctx.session,
        user,
      },
    },
  });
});
  • publicProcedure: Truly public data (health checks, public listings).
  • protectedProcedure: Any authenticated user action (default).
  • adminProcedure: Admin-only operations.

Creating API Routes

Create the repository

Create packages/api/src/routers/comment/comment.repository.ts:

import { db } from "@package/db";
import type { CreateCommentInput } from "./comment.input";

export const commentRepository = {
  getAll() {
    return db.comment.findMany({
      select: { id: true, text: true, createdAt: true },
    });
  },

  async create(data: CreateCommentInput) {
    return db.comment.create({
      data: { text: data.text },
      select: { id: true },
    });
  },
};

Create the input schema

Create packages/api/src/routers/comment/comment.input.ts:

import { z } from "zod";

export const createCommentInput = z.object({
  text: z.string().min(1).max(500),
});

export type CreateCommentInput = z.infer<typeof createCommentInput>;

Create the service

Create packages/api/src/routers/comment/comment.service.ts:

import { commentRepository } from "./comment.repository";
import type { CreateCommentInput } from "./comment.input";

export const commentService = {
  getAll() {
    return commentRepository.getAll();
  },

  async create(input: CreateCommentInput) {
    return commentRepository.create(input);
  },
};

Create the router

Create packages/api/src/routers/comment/comment.router.ts:

import { protectedProcedure, publicProcedure, router } from "../../trpc";
import { createCommentInput } from "./comment.input";
import { commentService } from "./comment.service";

export const commentRouter = router({
  getAll: publicProcedure.query(() => {
    return commentService.getAll();
  }),

  create: protectedProcedure.input(createCommentInput).mutation(({ input }) => {
    return commentService.create(input);
  }),
});

Register the router

Add it to packages/api/src/root.ts:

import { commentRouter } from "./routers/comment/comment.router";

export const appRouter = router({
  comment: commentRouter,
  // ...other routers
});

Calling API Routes

Server Components

import { api } from "@/lib/trpc/server";

export default async function CommentsPage() {
  const comments = await api.comment.getAll();

  return (
    <section>
      {comments.map((comment) => (
        <p key={comment.id}>{comment.text}</p>
      ))}
    </section>
  );
}

Client Components

"use client";

import { api } from "@/lib/trpc/react";

export function Comments() {
  const { data, isLoading } = api.comment.getAll.useQuery();

  if (isLoading) {
    return <Spinner />;
  }

  return (
    <section>
      {data?.map((comment) => (
        <p key={comment.id}>{comment.text}</p>
      ))}
    </section>
  );
}

Mutations

"use client";

import { api } from "@/lib/trpc/react";

export function AddComment() {
  const utils = api.useUtils();

  const mutation = api.comment.create.useMutation({
    onSuccess: async () => {
      await utils.comment.getAll.invalidate();
    },
  });

  return (
    <button onClick={() => mutation.mutate({ text: "Hello!" })} disabled={mutation.isPending}>
      Add Comment
    </button>
  );
}

Always invalidate related queries after mutations to refresh the data. Use utils.{router}.{procedure}.invalidate().