Architecture

API

How Init's contract-first oRPC API is structured, shared by every client, and extended

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 is contract-first. packages/contract/ declares every procedure - input, output, errors, OpenAPI metadata - and is the single source of truth. packages/service/ implements that contract on the server. Clients type their RPC calls against the contract, so the client stays free of server code (better-auth, Drizzle client, handlers). The one exception is the better-auth access-control roles (@repo/permissions), which web and mobile still import for permission checks:

packages/contract/src/
├── organization/          # organization-contract.ts, organization-schema.ts
├── todo/                  # todo-contract.ts, todo-schema.ts
├── waitlist/              # waitlist-contract.ts, waitlist-schema.ts
├── base.ts                # publicBase / protectedBase / organizationBase
└── index.ts               # contract, ContractClient, RouterInputs/Outputs

packages/service/src/
├── auth/                  # better-auth config + helpers
├── organization/          # organization-router.ts
├── todo/                  # Example CRUD implementation
├── waitlist/              # Waitlist signup
├── root-router.ts         # Implements the whole contract
├── orpc.ts                # Context, implementer, middleware
└── index.ts               # appRouter, AppRouter, createORPCContext

Each contract feature folder holds a *-contract.ts (procedure declarations) and *-schema.ts (Zod inputs). Each api feature folder holds a *-router.ts (handlers) 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;
  /** null means resolved and logged out; undefined triggers a lookup. */
  session?: Session | null;
}) => {
  const session =
    opts.session === undefined
      ? await auth.api.getSession({ headers: opts.headers })
      : opts.session;

  return { db, session };
};

2. Contract (@repo/contract)

oRPC has no .query / .mutation split - every procedure is declared once and implemented with .handler(), and handlers receive context rather than ctx.

Contracts are built with oc from @orpc/contract. base.ts declares three bases, each adding the error codes its procedures can raise:

// packages/contract/src/base.ts
export const publicBase = oc.errors({
  BAD_REQUEST: { message: "The input failed validation" },
});

/** Implementers apply `os.<feature>.use(requireSession)`; the contract only declares the outcome. */
export const protectedBase = publicBase
  .errors({ UNAUTHORIZED: { message: "No signed-in session" } })
  .meta(requireSession); // marks the operation with the `session` security scheme in /openapi.json

/** Implementations must `.use(requireOrganization)`, so every input extends `organizationInput`. */
export const organizationBase = protectedBase.errors({
  NOT_FOUND: { message: "The organization or a record in it does not exist" },
  UNAUTHORIZED: { message: "No signed-in session, or not a member of the organization" },
});

A feature contract picks a base and declares input and output:

// packages/contract/src/waitlist/waitlist-contract.ts
export const waitlistContract = {
  join: publicBase
    .meta(openapi({ summary: "Join the waitlist", description: "...", tags: ["Waitlist"] }))
    .input(joinWaitlistInput)
    .output(z.object({ waitlist: createSelectSchema(waitlist).nullable() })),
};

packages/contract/src/index.ts collects the feature contracts into one object:

export const contract = {
  organization: organizationContract,
  todo: todoContract,
  waitlist: waitlistContract,
};

3. Implementation (@repo/service)

orpc.ts binds the contract to the server context. os mirrors the contract's shape - os.todo.create is the implementer for that one procedure:

// packages/service/src/orpc.ts
export const os = implement(contract).$context<ORPCContext>();

Public procedures need no middleware:

// packages/service/src/waitlist/waitlist-router.ts
export const waitlistRouter = {
  join: os.waitlist.join.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 };
  }),
};

Session middleware

The contract only declares UNAUTHORIZED; the implementation enforces it. requireSession pairs with protectedBase and goes on the implementer (os.<feature>.use(requireSession)), where middleware runs before input validation - so an anonymous caller gets UNAUTHORIZED, never a BAD_REQUEST describing the input:

// packages/service/src/orpc.ts
/** Middleware is contract-independent, so tests can reuse it on their own contracts. */
const base = builder.$context<ORPCContext>(); // `os as builder` from @orpc/server

export const requireSession = base.middleware(({ 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

requireOrganization pairs with organizationBase and expects requireSession to have run. It resolves the organization named by input.slug, and proves the caller is a member before the handler runs:

// packages/contract/src/organization/organization-schema.ts
export const organizationInput = z.object({
  slug: z.string().min(1, "Organization slug is required"),
});

// packages/service/src/orpc.ts
type SessionContext = ORPCContext & { session: NonNullable<ORPCContext["session"]> };

export const requireOrganization = builder
  .$context<SessionContext>()
  .middleware(async ({ context, next }, input: z.infer<typeof organizationInput>) => {
    const organization = await context.db.query.organization.findFirst({
      where: { slug: input.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: { membership, organization } });
  });

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

Middleware .use()d on a procedure runs after input validation, so slug is already checked. Each contract declares its whole input, so org-scoped input schemas extend organizationInput, and requireOrganization only type-checks on procedures whose input carries a slug:

// packages/service/src/todo/todo-router.ts
const authed = os.todo.use(requireSession);

export const todoRouter = {
  create: authed.create.use(requireOrganization).handler(async ({ context, input }) => {
    // ...
  }),
  list: authed.list.use(requireOrganization).handler(async ({ context }) => {
    // ...
  }),
  // ...
};

Feature routers are plain objects, not os.todo.router({...}): oRPC's .router() re-applies the implementer's middleware, so requireSession would run twice. The root os.router still checks every procedure against the contract.

4. Router Composition

os.router assembles the feature routers and fails to compile if any contract procedure is missing or mistyped:

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

export type AppRouter = typeof appRouter;

Server-side code - the RPC and OpenAPI handlers, the server caller, the OpenAPI generator - uses appRouter from @repo/service. Clients use the contract.

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.

The client is typed by the contract, not the server router, so no server code reaches the client bundle:

// apps/web/src/orpc/react.tsx
import type { ContractClient } from "@repo/contract";

const client: ContractClient = createORPCClient(link);

export const orpc = createTanstackQueryUtils(client);

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/contract";

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

Cross-Platform

The same router serves web and mobile, and both clients are ContractClients. 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, in the contract package's *-schema.ts files:

// packages/contract/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. Forms import the same schemas for client-side validation (@repo/contract/waitlist/waitlist-schema).

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/service/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.

REST and OpenAPI

The same router is also served over oRPC's OpenAPI protocol at /api/v1 (apps/web/src/app/api/v1/[[...rest]]/route.ts), described by a generated OpenAPI 3.1 document at /openapi.json (apps/web/src/orpc/openapi.ts). It exists for agents and non-TypeScript clients; the app's own clients keep using RPC.

curl -X POST https://your-domain.com/api/v1/waitlist/join \
  -H 'content-type: application/json' -d '{"email":"me@example.com"}'

Every procedure is POST /<router>/<procedure> with the input as the JSON body, and unknown /api/* paths answer JSON too. The route runs the same origin check as RPC.

Documenting procedures

Each contract carries its own OpenAPI metadata and a typed output, so the spec describes what it returns and not just what it accepts:

// packages/contract/src/todo/todo-contract.ts
list: organizationBase
  .meta(openapi({ summary: "List todos", description: "...", tags: ["Todo"] }))
  .input(organizationInput)
  .output(z.object({ todos: z.array(todoRow) })),

.output() also validates at runtime, and it strips fields the schema leaves out, so it doubles as an allow-list. Output schemas live in *-contract.ts, not *-schema.ts, because they import Drizzle tables and the schema files are imported at runtime by client bundles; clients import the contract index only as a type. apps/web/src/orpc/openapi.test.ts fails if any operation is missing a summary, description, tags, a typed 200, or an error response pointing at the shared Error schema.

Errors

Every error body has one shape, the Error schema in the spec:

{
  "code": "UNAUTHORIZED",
  "message": "You must be logged in to access this resource",
  "defined": true
}

Branch on code. The contract bases declare the codes they raise with .errors() (BAD_REQUEST for input, UNAUTHORIZED for protected procedures, NOT_FOUND for organization-scoped ones), so each operation lists them with its own status. The route adds 403 FORBIDDEN (cross-origin browser request) and 500 INTERNAL_SERVER_ERROR.

Authentication

Operations that need a session reference the session security scheme: the better-auth session cookie, obtained by signing in (POST /api/auth/sign-in/email, or GitHub sign-in in the app). It is first-party session auth - there are no API keys, OAuth clients, or scopes.

Versioning and deprecation

v1 is stable. It only gains backward-compatible changes: new operations, new optional input fields, new output fields. A breaking change ships as a new major version at /api/v2, and /api/v1 keeps serving for at least 90 days after v2 launches. During that overlap every v1 response carries a Deprecation header (RFC 9745), a Sunset header with the shutdown date (RFC 8594), and a Link to the migration guide. The spec states the same policy under info.x-api-lifecycle.

Adding New Routes

1. Create Schema

// packages/contract/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. Declare the Contract

// packages/contract/src/post/post-contract.ts
import { post } from "@repo/db/drizzle-schema";
import { openapi } from "@orpc/openapi";
import { createSelectSchema } from "drizzle-orm/zod";
import { z } from "zod";

import { organizationBase } from "../base";
import { createPostInput } from "./post-schema";

export const postContract = {
  create: organizationBase
    .meta(openapi({ summary: "Create a post", description: "...", tags: ["Post"] }))
    .input(createPostInput)
    .output(z.object({ post: createSelectSchema(post) })),
};

Register it in packages/contract/src/index.ts:

export const contract = {
  organization: organizationContract,
  post: postContract, // Add here
  todo: todoContract,
  waitlist: waitlistContract,
};

If clients need the schema at runtime (form validation), add "./post/post-schema" to the exports in packages/contract/package.json.

3. Implement the Router

// packages/service/src/post/post-router.ts
import { post } from "@repo/db/drizzle-schema";
import { ORPCError } from "@orpc/server";

import { os, requireOrganization, requireSession } from "../orpc";

const authed = os.post.use(requireSession);

export const postRouter = {
  create: authed.create.use(requireOrganization).handler(async ({ context, input }) => {
    const [created] = await context.db
      .insert(post)
      .values({ organizationId: context.organization.id, title: input.title })
      .returning();
    if (!created) {
      throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Post was not created" });
    }
    return { post: created };
  }),
};

4. Add to Root Router

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

Until this line exists, os.router fails to compile: the contract declares post, so the implementation must provide it.

5. Use in Client

const createPost = useMutation(orpc.post.create.mutationOptions());

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/service test

Best Practices

  • Organize by feature - One folder per domain in each package: contract + schema in @repo/contract, router + test in @repo/service
  • Validate everything - Zod at every boundary
  • Check org access - Every multi-tenant contract extends organizationBase, and its implementation .use(requireOrganization), 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