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 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
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 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";
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 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 verifies membership
- Allow-list columns - Don't return full user rows; see
organization-router.ts
Next Steps
- Authentication - better-auth integration
- Database operations - Drizzle ORM usage
- AI features - The AI chat endpoint