Skip to content
Docs just relaunched - explore the new sidebar, OG images, and AI-ready content.
Build With SyntaxKit

API

Type-safe API layer with oRPC and TanStack Query.

Last updated on

8 min read

SyntaxKit's application API is a single oRPC router in packages/api, mounted at /rpc for the product app. The same router also drives an internal documentation surface at /api-reference: a generated OpenAPI spec plus interactive reference, gated to platform admins. Same router, same Zod schemas, same auth, no parallel contracts.

We pick oRPC over tRPC because it is OpenAPI-native, so the spec falls out of the router definition rather than needing a third-party plugin.

How A Request Flows

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

Two callers reach the same router. Browser components go through RPCLink to /rpc. React Server Components skip the network entirely and call procedures in-process via a router client seeded onto globalThis.$client. Once a request reaches the router, it walks the same middleware chain and lands in the same Zod-validated handler. /api-reference is a documentation surface only. It serves the OpenAPI spec and the interactive reference UI to platform admins, not a third callable mount.

The diagram source lives at apps/docs/diagrams/api-flow.mmd. Rerun pnpm --filter @syntaxkit/docs diagrams:build after editing it to refresh both SVG variants.

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 every procedure under a tagged namespace. Each card maps directly to a section in /api-reference and to a subtree of orpc.<namespace> on the client.

admin

Platform admin operations: list/get users, set role, ban/unban, manage sessions. Gated by requireAdmin.

chat

Org-scoped AI chats and messages, plus streaming send and regenerate procedures.

user

Session snapshot, profile updates, password set/change. Nested passkey and two-factor sub-namespaces.

organization

Create and list orgs, manage members and invitations, change roles. Permission-gated where it matters.

billing

Stripe checkout, billing portal, subscription state, cancel and resume.

dashboard

Aggregated stats and onboarding state for the dashboard landing.

storage

S3-compatible presign and finalize for avatar, logo, and chat image uploads.

contact

Public contact form. Verifies Turnstile and applies abuse throttling.

readinessCheck and livenessCheck

Top-level health procedures, also mirrored at /api/health and /api/healthz for monitoring.

Anatomy Of A Procedure

Every 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 typical 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 middleware is added. base provides headers. authorized adds session and user. withActiveOrganization adds organization. Each layer is a .use(...) chained off the previous, and the type of context inside the handler is the union of everything the chain has contributed.

Middleware And Context

Five middlewares cover every common gating pattern. Procedures opt in by chaining .use(...) after the base.

base

Defines the headers context and the typed error vocabulary (UNAUTHORIZED, FORBIDDEN, BAD_REQUEST, NOT_FOUND, TOO_MANY_REQUESTS, SERVICE_UNAVAILABLE). Source: 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. Source: packages/api/src/middleware/auth.ts.

withActiveOrganization

Loads the caller's active organization and adds it to context. Throws BAD_REQUEST when no org is active. Source: packages/api/src/middleware/organization.ts.

withPermission

Wraps Better Auth's hasPermission check. Used for org-scoped permissions like { organization: ['update'] }. Source: packages/api/src/middleware/permission.ts.

requireAdmin

Asserts the platform admin role on the current user. Used by every procedure under the admin namespace. Source: packages/api/src/middleware/admin.ts.

OpenAPI Reference

Every procedure that declares a .route({ path, method, summary }) is automatically published to the OpenAPI spec. The spec and an interactive reference UI are served by apps/web/app/api-reference/[[...rest]]/route.ts, which wraps the same router used for RPC behind a strict path allow-list and an in-handler admin session check:

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
}

A few practical notes:

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

Calling The API From React

Browser hooks

Client components import orpc from apps/web/lib/orpc and use TanStack Query's standard hooks. The query keys, input types, and result types are all inferred 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, the same pattern with 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 onto 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 pre-seeded client instead of constructing an RPCLink. Server components then use the same orpc.<namespace>.<procedure>.queryOptions() API as the browser, except calls execute in-process. Prefetch + 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 one part of the router that does not fit the standard request/response shape. chat.send and chat.regenerate return event iterators instead of plain values, so they intentionally omit .output():

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

On the client, the chat surface 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 documented on the AI page.

Adding A Procedure

Pick the right router file

Find the namespace your procedure belongs to under packages/api/src/router/. If it is a brand-new namespace, add a new file and register it in packages/api/src/router/index.ts under a base.tag("YourTag").router({ ... }) block so it shows up cleanly in /api-reference.

Define Zod schemas

Put schemas that more than one place uses in @syntaxkit/shared. Per-procedure ad-hoc schemas can stay inline. Reuse existing schemas where possible; many procedures share input shapes from the shared package.

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, .use(withPermission({ organization: ["update"] })) for finer-grained role checks.

Declare the route

Add .route({ path, method, summary }) so the procedure shows up correctly in the OpenAPI spec. Pick a path under the namespace's URL prefix, the HTTP verb that matches the operation, and a summary terse enough to read in the reference sidebar.

Add input and output

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

Implement the handler

Use ORPCError(code, ...) for typed errors. The full error vocabulary is defined 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 this set: generated typed clients narrow with isDefinedError against these codes, and any code thrown outside it falls back to oRPC's generic error path. Prefer SERVICE_UNAVAILABLE for missing-config or feature-disabled paths and reserve INTERNAL_SERVER_ERROR for genuine 500-class invariants. 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 new 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 re-exported from packages/api/src/client.ts updates automatically; no manual contract step.

Consumer code imports the type from @syntaxkit/api/client, which is a deliberately type-only re-export so server-side handler implementations never bleed into 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 bypass the network through the in-process $client.

Health And Webhooks

Two surfaces sit alongside the application API and are worth knowing about even though neither belongs to the oRPC router proper.

  • Health probes. The oRPC readinessCheck and livenessCheck procedures are exposed via /rpc, and the same checks are mirrored as plain Next route handlers 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 not oRPC procedures; they are Next route handlers at apps/web/app/api/webhooks/stripe/route.ts. They need raw request bodies for signature verification and Stripe-shaped responses, neither of which belongs in the typed RPC layer. Full pattern lives on Webhooks And Async Workflows.

Where To Go Next

Was this page helpful?

On this page