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 exportsEach 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 memberFORBIDDEN- Lacks permissionNOT_FOUND- Resource doesn't existBAD_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 testBest 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
- Authentication - better-auth integration
- Database operations - Drizzle ORM usage