Architecture

API

Init uses oRPC 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 oRPC?

oRPC 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
  • Rich serialization built in - Date, Map, Set, BigInt, URL, and File cross the wire without a transformer
  • 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
├── orpc.ts                # oRPC setup, context, procedures
└── index.ts               # Package exports

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

Core Components

1. Context (orpc.ts)

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

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

  return {
    session,
    db,
  };
};

const o = os.$context<ORPCContext>();

2. Procedures

oRPC has no .query / .mutation split - every procedure ends in .handler(), and handlers receive context rather than ctx.

Public Procedures

Available to everyone:

export const publicProcedure = o;

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

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

Protected Procedures

Require a session:

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

Organization Access

Org-scoping is a procedure factory. It takes the procedure's input schema, resolves the organization named by input.slug, 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/organization/organization-schema.ts
export const organizationInput = z.object({
  slug: z.string().min(1, "Organization slug is required"),
});

// packages/api/src/orpc.ts
export const organizationProcedure = <T extends z.ZodType<z.infer<typeof organizationInput>>>(
  input: T,
) =>
  protectedProcedure.input(input).use(async ({ context, next }, validated) => {
    const organization = await context.db.query.organization.findFirst({
      where: { slug: validated.slug },
    });

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

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

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

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

Handlers get context.organization and context.membership, and should still scope their queries by context.organization.id.

Every org-scoped procedure is written the same way, passing its own input schema:

organizationProcedure(organizationInput).handler(...)  // slug only
organizationProcedure(createTodoInput).handler(...)    // slug plus the feature's fields

A factory rather than a builder because oRPC does not merge .input() calls - each procedure declares its whole input, so the schema has to carry the slug. Wrapping both steps means the membership check cannot be left off: there is no half-applied form to reach for. That is why org-scoped schemas extend organizationInput.

3. Router Composition

Routers are plain objects - there is no router factory to wrap them in:

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

export type AppRouter = typeof appRouter;

Client Usage

Init uses the TanStack Query integration (@orpc/tanstack-query). orpc is a plain module export, not a React hook - there is no provider to thread through.

Queries and Mutations

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

// Query - the procedure input goes under an `input` key
const { data } = useSuspenseQuery(orpc.todo.list.queryOptions({ input: { slug } }));

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

Server Components

apps/web/src/orpc/server.tsx exposes a server-side caller and prefetch helpers. The caller runs procedures in-process, so SSR never asks the server to fetch from itself:

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

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

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

Cache Keys

.key() builds a partial-matching key for invalidation; .queryKey() builds a full-matching one for reading and writing cache entries directly:

queryClient.invalidateQueries({ queryKey: orpc.todo.list.key({ input: { slug } }) });
queryClient.setQueryData(orpc.todo.list.queryKey({ input: { slug } }), next);

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/orpc (see apps/web/src/app/api/orpc/[[...rest]]/route.ts); mobile points its client at the deployed URL (apps/mobile/src/utils/api.ts).

Input Validation

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

// packages/api/src/todo/todo-schema.ts
import { organizationInput } from "../organization/organization-schema";

const titleField = z.string().trim().min(1, "Title is required").max(255, "Title is too long");

export const createTodoInput = organizationInput.extend({
  title: titleField,
});

Org-scoped schemas extend organizationInput so the slug is part of the declared contract.

Rejected input reaches the client as a BAD_REQUEST error carrying the schema's issues.

Error Handling

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

// Handle in clients
const createTodo = useMutation(
  orpc.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

Cross-Origin Requests

CSRF protection is a cookie attribute plus an origin check, not a token.

packages/api/src/auth/auth.ts sets sameSite: "lax", so a request a third-party page originates arrives with no session — a forged multipart/form-data POST reaches the router and does nothing. RPCHandler refuses GET by default (allowMethods is ["POST", "PUT", "PATCH", "DELETE"]), closing the one method a cookie-bearing navigation can produce.

SameSite keys on site, not origin, so that covers cross-site only: a sibling subdomain or another port on the same host is same-site, and the browser does attach the session cookie to a form POST served from there. apps/web/src/app/api/orpc/[[...rest]]/route.ts closes that with an origin check ahead of handler.handle — an Origin header that is not the route's own origin gets 403. An absent Origin passes, which is what keeps the non-browser mobile client working; browsers set Origin on every POST and page script cannot forge it. route.test.ts pins all three cases.

Both halves hold because every surface authenticates first-party:

SurfaceHow it reaches the app
WebSame-origin
DesktopTop-level navigation to the deployed URL
ExtensionPopup opens the app in a tab
MobileReact Native sends the session as a Cookie header and no Origin — neither SameSite nor the origin check applies

Two things keep it true. Do not set sameSite: "none" for a surface that wants to embed the app — that re-opens cross-site CSRF for every client at once; make the surface first-party instead. And do not add CORS headers to the RPC route: a simple form POST needs no preflight, so CORS is not CSRF protection, while credentialed CORS would hand a cross-origin page authenticated access.

Adding New Routes

1. Create Schema

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

import { organizationInput } from "../organization/organization-schema";

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

2. Create Router

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

export const postRouter = {
  create: organizationProcedure(createPostInput).handler(async ({ context, input }) => {
    return context.db.insert(post).values({
      organizationId: context.organization.id,
      title: input.title,
    });
  }),
};

3. Add to Root Router

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

4. Use in Client

const { data } = useSuspenseQuery(orpc.post.list.queryOptions({ input: { slug } }));

Testing

Router tests run the real middleware and Drizzle query builders. test-utils.ts stubs only Postgres I/O, with queued rows and captured SQL. Tests cover validation, tenant filters, result mapping and missing rows; they do not replace a live database check.

import { createRouterClient } from "@orpc/server";
import { createMemberContext } from "../test-utils";
import { todoRouter } from "./todo-router";

const context = createMemberContext();
context.responses.push([]);
const caller = createRouterClient(todoRouter, { context });
const { todos } = await caller.list({ slug: "acme" });
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 is built with 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