Architecture

API

Kit uses tRPC v11 for end-to-end typesafe APIs shared by the Next.js web app and Expo mobile app. This guide covers how the API is structured and how to extend it.

What is tRPC?

tRPC gives you typesafe APIs without code generation. Types flow from database to UI - autocompletion, inline errors, zero runtime schema drift.

Key Benefits

  • End-to-end type safety - Types flow from database to UI
  • No code generation - TypeScript inference handles everything
  • Excellent DX - Autocompletion and inline errors
  • Framework agnostic - Works with React, React Native, and more

API Structure

The API lives in packages/api/:

packages/api/src/
├── auth/                  # better-auth config + helpers
├── organization/          # Organization procedures
├── todo/                  # Example CRUD procedures
├── waitlist/              # Waitlist signup
├── root-router.ts         # Router composition
├── trpc.ts                # tRPC setup, context, procedures
└── index.ts               # Package exports

Each feature folder holds a *-router.ts (procedures), *-schema.ts (Zod inputs), and *.test.ts (Vitest).

Core Components

1. Context (trpc.ts)

The context resolves the better-auth session and exposes the database:

export const createTRPCContext = async (opts: { headers: Headers }) => {
  const session = await auth.api.getSession({
    headers: opts.headers,
  });

  return {
    session,
    db,
  };
};

2. Procedures

Public Procedures

Available to everyone:

export const publicProcedure = t.procedure;

// packages/api/src/waitlist/waitlist-router.ts
export const waitlistRouter = createTRPCRouter({
  join: publicProcedure.input(joinWaitlistInput).mutation(async ({ ctx, input }) => {
    const [created] = await ctx.db
      .insert(waitlist)
      .values({ ...input, userId: ctx.session?.user.id })
      .onConflictDoNothing({ target: waitlist.email })
      .returning();

    return { waitlist: created ?? null };
  }),
});

Protected Procedures

Require a session:

export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be logged in to access this resource",
    });
  }
  return next({
    ctx: {
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});

Organization Access

Membership checks happen per-router. The todo router shows the pattern:

// packages/api/src/todo/todo-router.ts
const ensureOrganizationAccess = async (ctx: TRPCContext, slug: string) => {
  const organization = await ctx.db.query.organization.findFirst({
    where: (org, { eq }) => eq(org.slug, slug),
  });
  if (!organization) throw new TRPCError({ code: "NOT_FOUND" });

  const membership = await ctx.db.query.member.findFirst({
    where: (member, { and, eq }) =>
      and(eq(member.organizationId, organization.id), eq(member.userId, userId)),
  });
  if (!membership) throw new TRPCError({ code: "UNAUTHORIZED" });

  return organization;
};

3. Router Composition

// packages/api/src/root-router.ts
export const appRouter = createTRPCRouter({
  waitlist: waitlistRouter,
  organization: organizationRouter,
  todo: todoRouter,
});

export type AppRouter = typeof appRouter;

Client Usage

Kit uses the TanStack React Query integration (@trpc/tanstack-react-query).

Queries and Mutations

import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { useTRPC, useTRPCClient } from "@/trpc/react";

const trpc = useTRPC();
const trpcClient = useTRPCClient();

// Query
const { data } = useSuspenseQuery(trpc.todo.list.queryOptions({ slug }));

// Mutation
const createTodo = useMutation({
  mutationFn: (input: CreateTodoInput) => trpcClient.todo.create.mutate(input),
});

Server Components

apps/web/src/trpc/server.tsx exposes a server-side caller and prefetch helpers:

import { caller, HydrateClient, prefetch, trpc } from "@/trpc/server";

// Direct call in a server component or route handler
const { todos } = await caller.todo.list({ slug });

// Prefetch for client components
prefetch(trpc.todo.list.queryOptions({ slug }));

Type Helpers

import type { RouterInputs, RouterOutputs } from "@repo/api";

type Todo = RouterOutputs["todo"]["list"]["todos"][number];
type CreateTodoInput = RouterInputs["todo"]["create"];

Cross-Platform

The same router serves web and mobile. Web hits /api/trpc (see apps/web/src/app/api/trpc/[trpc]/route.ts); mobile points its client at the deployed URL (apps/mobile/src/utils/api.tsx).

Input Validation

All procedures validate inputs with Zod, colocated in *-schema.ts:

// packages/api/src/todo/todo-schema.ts
export const createTodoInput = z.object({
  slug: z.string(),
  title: z.string().min(1),
});

Validation errors flatten into zodError via the error formatter in trpc.ts.

Error Handling

// Throw in procedures
throw new TRPCError({
  code: "NOT_FOUND",
  message: "Todo not found",
});

// Handle in clients
const createTodo = useMutation({
  mutationFn: (input: CreateTodoInput) => trpcClient.todo.create.mutate(input),
  onError: (error) => toast.error(error.message),
});

Common Error Codes

  • UNAUTHORIZED - Not logged in or not a member
  • FORBIDDEN - Lacks permission
  • NOT_FOUND - Resource doesn't exist
  • BAD_REQUEST - Invalid input

Adding New Routes

1. Create Schema

// packages/api/src/post/post-schema.ts
import { z } from "zod";

export const createPostInput = z.object({
  slug: z.string(),
  title: z.string().min(1).max(255),
});

2. Create Router

// packages/api/src/post/post-router.ts
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createPostInput } from "./post-schema";

export const postRouter = createTRPCRouter({
  create: protectedProcedure.input(createPostInput).mutation(async ({ ctx, input }) => {
    const organization = await ensureOrganizationAccess(ctx, input.slug);
    return ctx.db.insert(post).values({
      organizationId: organization.id,
      title: input.title,
    });
  }),
});

3. Add to Root Router

// packages/api/src/root-router.ts
export const appRouter = createTRPCRouter({
  waitlist: waitlistRouter,
  organization: organizationRouter,
  todo: todoRouter,
  post: postRouter, // Add here
});

4. Use in Client

const { data } = useSuspenseQuery(trpc.post.list.queryOptions({ slug }));

Testing

Routers ship with Vitest tests next to the code (todo-router.test.ts). Shared helpers live in packages/api/src/test-utils.ts.

pnpm -F @repo/api test

Best Practices

  • Organize by feature - One folder per domain, router + schema + test
  • Validate everything - Zod at every boundary
  • Check org access - Every multi-tenant procedure verifies membership
  • Allow-list columns - Don't return full user rows; see organization-router.ts

Next Steps

  1. Authentication - better-auth integration
  2. Database operations - Drizzle ORM usage
  3. AI features - The AI chat endpoint

On this page