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 readOn This Page
How A Request Flows
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
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.
| Middleware | What it does | Source |
|---|---|---|
base | Defines 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 |
withActiveOrganization | Loads the caller's active organization and adds it to context. Throws BAD_REQUEST when no org is active. | packages/api/src/middleware/organization.ts |
withOrganizationAccess | Same 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 |
withPermission | Wraps Better Auth's hasPermission check. Used for org-scoped permissions like { organization: ["update"] }. | packages/api/src/middleware/permission.ts |
requireAdmin | Asserts 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-referencein the running app. It is admin-only in every environment.apps/web/proxy.tsredirects 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
GETandHEADare wired, and only the docs UI andspec.jsonare served./api-referenceis not a callable REST mount. Procedure paths under that prefix return 404. The single API surface is/rpc. - Section titles come from
base.tag(...)inpackages/api/src/router/index.ts. The same tags group the namespaces above. - Responses set
X-Robots-Tag: noindex, nofollowandCache-Control: private, no-store./api-reference/is also disallowed inapps/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.
readinessCheckandlivenessCheckare available over/rpc. The same checks are also plain Next routes atapps/web/app/api/health/route.tsandapps/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
Database
The Prisma schema and client every procedure reads and writes through.
Authentication
How Better Auth supplies the session and permission checks the auth and admin middlewares depend on.
Also useful: Billing, AI, Webhooks And Async Workflows, and Conventions.
Database
PostgreSQL via Prisma in packages/database. Point DATABASE_URL at a hosted provider or Docker; the client picks the driver adapter. Schema layout, migrations, seeding, and how the models connect.
Authentication
Better Auth in packages/auth for email/password, OAuth, passkeys, and 2FA. Organizations, platform admin, Turnstile, and abuse throttling ship on top. Sessions carry the active organization. Identity models live in auth.generated.prisma.
