| name | orpc-api |
| description | Create and modify oRPC API routers, procedures, and middleware for the builder app. Use when adding API endpoints, creating routers, defining procedures, working with oRPC middleware, or building OpenAPI routes. |
oRPC API Development
Architecture
- oRPC serves both RPC (
/rpc) and OpenAPI (/api) endpoints
- Base context:
{ headers, user?, workspace? }
- Two auth stacks:
authorizedAPI (session) and workspaceTokenAuthAPI (header token)
- Routers are plain objects of procedures, composed via object spreading
Auth Stacks
Defined in apps/builder/src/orpc.ts:
authorizedAPI: base → error mapping → authMiddleware (session/cookie auth)
workspaceTokenAuthAPI: base → error mapping → workspaceTokenAuthMidddleware (Authorization: Bearer header)
Workspace-scoped procedures add workspaceAuthorizedMidddleware per-procedure.
Creating a New Procedure
import { authorizedAPI } from "@/orpc"
import { workspaceAuthorizedMidddleware } from "@/middlewares/auth"
import { z } from "zod"
import { zodBigintAsString } from "@chatbotx.io/database/schema"
export const myFeatureAuthenticatedAPI = {
listMyFeatureAPI: authorizedAPI
.route({
method: "GET",
path: "/workspaces/{workspaceId}/my-feature",
summary: "List my feature items",
tags: ["MyFeature"],
})
.input(
z.object({
workspaceId: zodBigintAsString(),
perPage: z.coerce.number().optional(),
cursor: z.string().optional(),
}),
)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
.output(myFeatureListResponse)
.handler(async ({ input, context }) => {
return await listMyFeature(input)
}),
createMyFeatureAPI: authorizedAPI
.route({
method: "POST",
path: "/workspaces/{workspaceId}/my-feature",
summary: "Create a new item",
tags: ["MyFeature"],
})
.input(createMyFeatureRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
.handler(async ({ input }) => {
return await createMyFeature(input)
}),
}
Procedure Chain
authorizedAPI
.route({ method, path, summary, tags }) → OpenAPI metadata
.input(zodSchema) → request validation
.use(middleware, mapperFn) → per-procedure middleware (optional)
.output(zodSchema) → response validation (optional)
.handler(async ({ input, context }) => {})→ business logic
Feature API Structure
Each feature has api/ directory with optional split:
features/my-feature/
api/
index.ts → merges authenticated + workspace-token APIs
authenticated.ts → session-based procedures
workspace-token.ts → token-based procedures (for public API)
api/index.ts
import { myFeatureAuthenticatedAPI } from "./authenticated"
import myFeatureWorkspaceTokenAPIs from "./workspace-token"
export const myFeatureAPI = {
...myFeatureWorkspaceTokenAPIs,
...myFeatureAuthenticatedAPI,
}
Workspace-token procedures (public API)
import { workspaceTokenAuthAPI } from "@/orpc"
const workspaceTokenAPIs = {
findMyFeaturePublicAPI: workspaceTokenAuthAPI
.route({
method: "GET",
path: "/v1/my-feature/{id}",
summary: "Get item by ID",
tags: ["MyFeature"],
})
.input(z.object({ id: zodBigintAsString() }))
.output(publicMyFeatureResponse)
.handler(async ({ context, input }) => {
return await findMyFeature({
id: input.id,
workspaceId: context.workspace.id,
})
}),
}
export default workspaceTokenAPIs
Registering the Router
Add to apps/builder/src/routers/index.ts as a lazy branch — every feature
router there is wrapped in oRPC's lazy() so the feature's api module (and its
import graph) only loads on the first call that targets it, instead of all ~58
feature api modules loading with the route handler:
import { lazy } from "@orpc/server"
export const router = {
myFeatureAPI: lazy(() =>
import("@/features/my-feature/api").then((m) => ({
default: m.myFeatureAPI,
})),
),
}
Two failure modes to watch: the import("...") specifier must stay a literal
string (the bundler needs to statically analyze it), and the picked export
name must match the module's actual export — a mismatch produces
default: undefined, which fails at the first call to that branch rather than
at build time. Dynamic import() is allowed here because apps/builder is
Next.js-built (see .agents/rules/no-dynamic-import.md).
For public API (workspace-token), also add to apps/builder/src/routers/public.ts —
that router stays eager (plain imports); it feeds /api/public-spec.json.
Schema Patterns
Schemas live in features/<feature>/schema/:
export const listMyFeatureRequest = z.object({
workspaceId: zodBigintAsString(),
perPage: z.coerce.number().optional(),
cursor: z.string().optional(),
keyword: z.string().optional(),
})
export const myFeatureResponse = z.object({
id: z.string(),
name: z.string(),
createdAt: z.date(),
})
import { withWorkspaceIdSchema } from "@/features/workspaces/schema/resource"
Client Usage
Browser (client components)
import { client } from "@/lib/orpc/orpc"
const data = await client.myFeatureAPI.listMyFeatureAPI({ workspaceId })
Server (SSR)
const data = await client.myFeatureAPI.listMyFeatureAPI({ workspaceId })
Error Handling
Throw ChatbotXException or ModelNotfoundException — they are auto-mapped to oRPC errors in the global onError interceptor:
import { ChatbotXException, notFoundException } from "@chatbotx.io/sdk"
throw notFoundException("Item not found")
throw new ChatbotXException("Custom error", "BAD_REQUEST", 400)
Logging
Import the logger from the nearest lib/log or lib/logger module. Never use console in handlers.
import { logger } from "../lib/log"
.handler(async ({ input }) => {
try {
return await doWork(input)
} catch (error) {
logger.error({ err: error, ...input }, "[myFeature] handler failed")
throw error
}
})
Key rule: always use err: error (not error: error) so pino serializes the stack trace.