Skip to content

.procedure()

Register RPC procedures with .procedure(), mount raw routes with .route(), extract router types, and expose OpenAPI and REST.

Updated View as Markdown

.resource() cover CRUD. Everything else — a search endpoint, a webhook, a job trigger — is a procedure or a raw route.

Register a procedure

.procedure(name, cb, options?) registers an oRPC procedure. name supports dot notation: "user.me" nests the procedure as { user: { me: proc } } and serves it at POST /rpc/user/me. Procedures under the same namespace are deep-merged.

.procedure("user.me",     (base) => base.handler(...))
.procedure("user.update", (base) => base.input(z.object({...})).handler(...))

The callback receives the oRPC base builder, so chain .input(), .output(), and .handler() on it. Handlers get the request context, including context.db and the resolved session.

Outer infers each procedure’s input and output types into the router, so client.user.me(...) is fully typed. See Extract the router type.

Guard a procedure with the optional third argument, { permission, roles? } — see Permissions.

Registering the same dot-path twice throws immediately. That covers two .procedure() calls and a name colliding with a resource-generated action, so a second registration never silently overwrites the first.

Enrich context with middleware

.middleware(mw) adds an oRPC middleware to the base context. Whatever it returns via next({ context }) is merged into context — and into the types — for every procedure defined after it.

.middleware(async ({ next }) => next({ context: { kv: useStorage() } }))
.procedure("cache.get", (base) =>
  base.input(z.object({ key: z.string() })).handler(async ({ context, input }) => {
    return context.kv.getItem(input.key);
  }),
)

Call .middleware() before the procedures that need the extra fields. Auth stays on .auth() — do not re-resolve the session in middleware.

Throw errors

Import ORPCError from @outerjs/server and throw it for expected failures. Clients see a typed error; onError is not called.

import { ORPCError } from "@outerjs/server";

.procedure("post.publish", (base) =>
  base.input(z.object({ id: z.number() })).handler(async ({ context, input }) => {
    const post = await context.db.query.post.findFirst({ where: { id: input.id } });
    if (!post) throw new ORPCError("NOT_FOUND", { message: "Post not found" });
    if (post.authorId !== context.user?.id) {
      throw new ORPCError("FORBIDDEN", { message: "Not your post" });
    }
    // ...
  }),
)

Common codes: BAD_REQUEST (400), UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409). Resource-generated CRUD maps Postgres constraint violations the same way — see Resources → Errors.

Mount a raw route

.route(method, path, handler) mounts a raw H3 route for webhooks, custom REST endpoints, or anything that does not fit the oRPC shape.

.route("post", "/webhooks/stripe", async (event, { db }) => {
  const body = await event.req.json();
  // ...
  return new Response("ok");
})

handler receives the H3 event and the same context procedures get, including the already-resolved session, so raw routes authorize the same way. Raw routes are registered before /rpc/** and win on overlapping paths.

Extract the router type

Both the Outer instance and BuiltOuter expose a router property carrying the internal oRPC router type. Extract it with the exported InferRouter<T> helper:

// src/index.ts
export const outer = new Outer(...)
  .schema(v1_0)
  .procedure("user.me", (base) => base.handler(...))
  .build();
// outer.types.ts
import type { RouterClient } from "@orpc/server";
import type { InferRouter } from "@outerjs/server";
import type { outer } from "./src/index.js";

export type Router = InferRouter<typeof outer>;
export type AppClient = RouterClient<Router>;

Outer’s core has no CLI. Write that file by hand, or generate it with your own script.

Serve OpenAPI and REST

.openapi(config?) mounts GET /openapi.json and a plain-JSON REST surface at /rest/**. Neither is mounted unless you call it, and calling it with no arguments enables both. It can appear anywhere before .build(). Requires the optional peers @orpc/openapi and @orpc/zod.

.openapi() // always enabled
.openapi({ enabled: import.meta.env.DEV }) // dev and staging only
Option Type Default Description
enabled boolean true Whether to mount /openapi.json and /rest/**

The /rpc/** handler speaks oRPC’s own wire protocol, which generic OpenAPI clients cannot use. So when .openapi() is enabled, the same router is also served through oRPC’s OpenAPIHandler under /rest/**, matching the generated spec exactly.

Surface Path Audience
Typed RPC POST /rpc/<dot/path> @outerjs/sdk
OpenAPI JSON GET /openapi.json Spec consumers, Hub, codegen
Plain REST /rest/** Curl, Postman, non-oRPC clients

Call REST with plain JSON — for example POST /rest/post/create with {"title": "..."}. Dot-separated procedure names become path segments under /rest. The spec advertises <baseUrl>/rest as its server URL.

The generated document is OpenAPI 3.x, from @orpc/openapi. Its title comes from the name param and its version from the last registered schema. Procedures with .input(zodSchema) and .output(zodSchema) are fully documented. Output schemas are not inferred from handler return types, so add an explicit .output() when you want responses documented.

Next steps

  • Client — call these procedures from the browser or SSR
  • Database — query inside your handlers
  • Permissions — guard what you just registered
  • .mcp() — expose procedures as agent tools
Navigation

Type to search…

↑↓ navigate↵ selectEsc close