Skip to content
Build With SyntaxKit

API

One oRPC router in packages/api, mounted at /rpc. Same router and Zod schemas feed the admin-only OpenAPI reference at /api-reference. Middleware, namespaces, React callers, streaming chat, and how to add a procedure.

Last updated on

8 min read

On This Page

How A Request Flows

Request lifecycle: browser, OpenAPI client, and React Server Components all converge on the same packages/api router

Product calls hit one router in packages/api. The kit uses oRPC (not tRPC) so the OpenAPI spec falls out of the same router definition.

Browser components send RPC over RPCLink to /rpc. React Server Components skip the network and call the same procedures in-process through a router client on globalThis.$client.

Either way, the request walks the same middleware chain and lands in the same Zod-validated handler.

/api-reference is docs only. It serves the OpenAPI spec and interactive UI to platform admins. It is not a third place to call procedures.

Package Layout

index.tsPublic entry: exports router and the Router type
client.tsType-only re-export of Router for consumer bundles
index.tsComposes every sub-router into the root and applies OpenAPI tags
admin.tsPlatform admin: users, sessions, roles
billing.tsStripe checkout, portal, subscription state
chat.tsOrg-scoped AI chat including streaming send/regenerate
contact.tsPublic contact form with Turnstile + abuse policy
dashboard.tsAggregated stats for the dashboard landing
health.tsReadiness and liveness checks
organization.tsOrgs, members, invitations, role updates
storage.tsThin adapter over @syntaxkit/storage: auth, abuse, and StorageError->ORPCError mapping for presign/finalize
two-factor.tsTOTP enroll/verify/disable + backup codes
user.tsSession snapshot, profile updates, passkey ops

Namespaces

The root router groups procedures under tagged namespaces. Each row is a section in /api-reference and a subtree of orpc.<namespace> on the client.

Anatomy Of A Procedure

A procedure is a chain: pick a base, add middleware, declare the OpenAPI route, declare input/output schemas, then write the handler.

Here is a typical query, getUserSession from packages/api/src/router/user.ts:

export const getUserSession = authorized
  .route({
    path: "/user/session",
    method: "GET",
    summary: "Get user session",
  })
  .output(userSessionOutputSchema)
  .handler(async ({ context }) => {
    const credentialAccount = await prisma.account.findFirst({
      where: { userId: context.user.id, providerId: "credential" },
      select: { id: true },
    });

    return {
      user: { /* ... */ },
      activeOrganizationId: context.session.activeOrganizationId ?? null,
      hasPasswordAuth: !!credentialAccount,
    };
  });

A mutation looks the same with .input(...) added. createOrganization from packages/api/src/router/organization.ts:

export const createOrganization = authorized
  .route({
    path: "/organization/create",
    method: "POST",
    summary: "Create organization",
  })
  .input(organizationCreateSchema)
  .output(z.object({ id: z.string() }))
  .handler(async ({ context, input }) => {
    const org = await auth.api.createOrganization({ /* ... */ });

    if (!org) {
      throw new ORPCError("INTERNAL_SERVER_ERROR", {
        message: "Failed to create organization",
      });
    }

    return { id: org.id };
  });

Context grows as you chain middleware. base provides headers. authorized adds session and user. withActiveOrganization adds organization. Inside the handler, context is typed as everything the chain contributed.

Middleware And Context

Procedures opt into gates by chaining .use(...) after a base.

MiddlewareWhat it doesSource
baseDefines the headers context and the typed error vocabulary (UNAUTHORIZED, FORBIDDEN, BAD_REQUEST, NOT_FOUND, TOO_MANY_REQUESTS, SERVICE_UNAVAILABLE).packages/api/src/middleware/base.ts
authMiddleware (authorized)Resolves the Better Auth session, throws UNAUTHORIZED if missing, adds session and user to context. Exposed as the authorized procedure base.packages/api/src/middleware/auth.ts
withActiveOrganizationLoads the caller's active organization and adds it to context. Throws BAD_REQUEST when no org is active.packages/api/src/middleware/organization.ts
withOrganizationAccessSame as withActiveOrganization, plus asserts input.organizationId matches the active org. Use when a procedure takes an org id explicitly.packages/api/src/middleware/organization.ts
withPermissionWraps Better Auth's hasPermission check. Used for org-scoped permissions like { organization: ["update"] }.packages/api/src/middleware/permission.ts
requireAdminAsserts the platform admin role on the current user. Used by every procedure under the admin namespace.packages/api/src/middleware/admin.ts

OpenAPI Reference

Every procedure that declares .route({ path, method, summary }) is published to the OpenAPI spec. The spec and interactive UI live at apps/web/app/api-reference/[[...rest]]/route.ts. That route wraps the same router, allows only a few paths, and checks for an admin session:

const ALLOWED_PATHS = new Set([
  "/api-reference",
  "/api-reference/",
  "/api-reference/spec.json",
]);

async function handleRequest(request: Request): Promise<Response> {
  const url = new URL(request.url);
  if (!ALLOWED_PATHS.has(url.pathname)) return notFound();

  const session = normalizeSession(
    await auth.api.getSession({ headers: request.headers })
  );
  if (!session || !isAdmin(session.user.role)) return notFound();

  // ...delegates to OpenAPIHandler for the docs UI and spec.json
}

Practical notes:

  • Visit /api-reference in the running app. It is admin-only in every environment. apps/web/proxy.ts redirects unauthenticated users to login. The handler then decrypts the session and returns 404 for every non-admin (including signed-in non-admins), so the route's existence stays hidden.
  • Only GET and HEAD are wired, and only the docs UI and spec.json are served. /api-reference is not a callable REST mount. Procedure paths under that prefix return 404. The single API surface is /rpc.
  • Section titles come from base.tag(...) in packages/api/src/router/index.ts. The same tags group the namespaces above.
  • Responses set X-Robots-Tag: noindex, nofollow and Cache-Control: private, no-store. /api-reference/ is also disallowed in apps/web/app/robots.ts.

Calling The API From React

Browser hooks

Client components import orpc from apps/web/lib/orpc and use TanStack Query hooks. Query keys, input types, and result types come from the Router type.

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

export function DashboardContent() {
  const { data } = useSuspenseQuery(orpc.dashboard.getStats.queryOptions());
  // ...
}

For mutations, use mutationOptions(). For typed error narrowing, use isDefinedError from @orpc/client against the error's code.

Server components

React Server Components do not make HTTP calls. The root layout imports apps/web/lib/orpc.server.ts at boot, which seeds an in-process router client on globalThis.$client:

globalThis.$client = createRouterClient(router, {
  context: async () => ({
    headers: await headers(),
  }),
});

When apps/web/lib/orpc.ts initializes on the server, it picks up that client instead of building an RPCLink. Server components then use the same orpc.<namespace>.<procedure>.queryOptions() API as the browser; calls run in-process. Prefetch and hydration helpers live in apps/web/lib/query/hydration.tsx.

RPCLink throws if instantiated on the server. Always import orpc from @/lib/orpc in client components, and rely on the globalThis.$client seeding for server components. Do not build a second client manually.

Streaming Procedures

AI chat is the part of the router that does not fit request/response. chat.send and chat.regenerate return event iterators instead of plain values, so they omit .output():

return streamToEventIterator(
  result.toUIMessageStream({ sendReasoning: true, sendSources: true })
);

On the client, the chat UI uses the AI SDK's useChat with a custom transport that calls the procedure and unproxies the stream via eventIteratorToUnproxiedDataStream from @orpc/client. See packages/api/src/router/chat.ts and apps/web/components/dashboard/ai-chat/chat-view.tsx. The full AI surface is on the AI page.

Adding A Procedure

Pick the right router file

Find the namespace under packages/api/src/router/. For a new namespace, add a file and register it in packages/api/src/router/index.ts under base.tag("YourTag").router({ ... }) so it appears cleanly in /api-reference.

Define Zod schemas

Put shared schemas in @syntaxkit/shared. Per-procedure schemas can stay inline. Reuse existing shapes when they already fit.

Pick the procedure base

Start from base for public endpoints. Use authorized for signed-in users. Chain .use(withActiveOrganization) for org-scoped data, .use(requireAdmin) for platform admin, or .use(withPermission({ organization: ["update"] })) for finer role checks.

Declare the route

Add .route({ path, method, summary }) so the procedure shows up in the OpenAPI spec. Use a path under the namespace prefix, the matching HTTP verb, and a short summary for the reference sidebar.

Add input and output

Chain .input(zodSchema) and .output(zodSchema). Output schemas are the return type contract. Prisma return types do not flow automatically, so an explicit output schema keeps the OpenAPI surface honest.

Implement the handler

Throw typed errors with ORPCError(code, ...). The allowed codes live in packages/api/src/middleware/base.ts: UNAUTHORIZED, FORBIDDEN, BAD_REQUEST, NOT_FOUND, CONFLICT, TOO_MANY_REQUESTS, SERVICE_UNAVAILABLE, and INTERNAL_SERVER_ERROR.

Stay inside that set. Generated clients narrow with isDefinedError against these codes; anything else falls back to oRPC's generic error path. Prefer SERVICE_UNAVAILABLE for missing config or disabled features. Reserve INTERNAL_SERVER_ERROR for real 500-class failures.

A router error contract test in packages/api/src/router/error-contract.test.ts scans every procedure, guard, middleware, and lib file and fails CI if a throw drifts outside the declared set.

Wire it into the root router

Export the procedure from its file and add it to the namespace object in packages/api/src/router/index.ts. The Router type from packages/api/src/client.ts updates automatically.

Consumer code imports the type from @syntaxkit/api/client, a type-only re-export so server handler code never lands in the browser bundle.

Consume it from React

Use orpc.<namespace>.<procedure>.queryOptions(input) for queries or .mutationOptions() for mutations. Server components use the same API and skip the network through the in-process $client.

Health And Webhooks

Two surfaces sit next to the app API. Neither is part of the oRPC router itself.

  • Health probes. readinessCheck and livenessCheck are available over /rpc. The same checks are also plain Next routes at apps/web/app/api/health/route.ts and apps/web/app/api/healthz/route.ts, so monitoring tools that do not speak RPC can hit them directly.
  • Inbound webhooks. Stripe webhooks are Next route handlers at apps/web/app/api/webhooks/stripe/route.ts, not oRPC procedures. They need raw request bodies for signature verification and Stripe-shaped responses. Full pattern: Webhooks And Async Workflows.

Where To Go Next

Also useful: Billing, AI, Webhooks And Async Workflows, and Conventions.

Was this page helpful?

On this page