Architecture

API

Init 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

Org-scoping is its own procedure, built on protectedProcedure. organizationProcedure takes an organization slug, resolves it, and proves the caller is a member before the handler runs - so a handler cannot read or write another tenant's rows by forgetting a check:

// packages/api/src/trpc.ts
export const organizationProcedure = protectedProcedure
  .input(z.object({ slug: z.string().min(1, "Organization slug is required") }))
  .use(async ({ ctx, input, next }) => {
    const organization = await ctx.db.query.organization.findFirst({
      where: (org, { eq }) => eq(org.slug, input.slug),
    });

    if (!organization) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: "Organization not found",
      });
    }

    const membership = await ctx.db.query.member.findFirst({
      where: (member, { and, eq }) =>
        and(eq(member.organizationId, organization.id), eq(member.userId, ctx.session.user.id)),
    });

    if (!membership) {
      throw new TRPCError({
        code: "UNAUTHORIZED",
        message: "You do not have access to this organization",
      });
    }

    return next({ ctx: { organization, membership } });
  });

Handlers get ctx.organization and ctx.membership, and should still scope their queries by ctx.organization.id. The slug input merges with whatever .input() the procedure adds, so feature schemas carry only their own fields.

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

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

Queries and Mutations

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

const trpc = useTRPC();

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

// Mutation
const createTodo = useMutation(trpc.todo.create.mutationOptions());

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
const titleField = z.string().trim().min(1, "Title is required").max(255, "Title is too long");

export const createTodoInput = z.object({
  title: titleField,
});

Org-scoped schemas don't declare slug - organizationProcedure merges its own { slug } input with them.

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(
  trpc.todo.create.mutationOptions({
    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";

// `slug` + the membership check come from `organizationProcedure` -
// only the post's own fields here
export const createPostInput = z.object({
  title: z.string().min(1).max(255),
});

2. Create Router

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

export const postRouter = createTRPCRouter({
  create: organizationProcedure.input(createPostInput).mutation(async ({ ctx, input }) => {
    return ctx.db.insert(post).values({
      organizationId: ctx.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 builds on organizationProcedure, so membership is proven before the handler runs
  • 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

On this page