For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
OpenAPI
Add OpenAPI docs to the TurboStarter API. Document routes once and unlock SDKs, agents, and partner integrations.
OpenAPI is the standard way to describe HTTP APIs. In TurboStarter, it sits next to the existing Hono RPC client - you keep end-to-end TypeScript types for first-party apps, and you also get a machine-readable contract that tools, partners, and AI agents can consume.
That second audience matters more than ever. Agents, MCP servers, codegen tools, and API gateways all speak OpenAPI. When your routes are documented with Zod schemas, you can hand an agent a single URL and let it call your API safely - without teaching it your TypeScript types by hand.
TL;DR
Annotate Hono routes with a thin document() helper (summary + response Zod schema), serve an OpenAPI document at /api/openapi, and browse everything in Scalar at /api/docs. Better Auth contributes a second schema source for auth endpoints. Your existing RPC clients stay unchanged.

Why OpenAPI in TurboStarter?
Hono RPC already gives you type-safe calls inside the monorepo. OpenAPI covers everything outside that closed loop:
| Audience | What OpenAPI unlocks |
|---|---|
| AI agents & MCP | Feed /api/openapi to an agent so it can discover operations, validate payloads, and call your product API |
| External clients | Generate typed SDKs for Python, Go, Swift, or any language your customers use |
| Partners & integrators | Share an interactive Scalar portal instead of a Notion dump of endpoints |
| QA & contract tests | Diff the OpenAPI document in CI, or generate smoke tests from the schema |
| API gateways | Import the document into Kong, Cloudflare, or similar for routing and auth policies |
You document once in Zod. Runtime validation, RPC inference, and the OpenAPI document all stay aligned.
What's included?
TurboStarter wires three pieces together:
OpenAPI document
Generated from annotated Hono routes at /api/openapi.
Scalar UI
Interactive reference at /api/docs with Try it support.
Auth schema
Better Auth OpenAPI plugin as a second Scalar source.
Covered out of the box (once annotated):
- Organizations - members, invitations, slug helpers
- Billing - summary, usage, checkout, portal (webhooks stay undocumented on purpose)
- Storage - upload, signed, public, and delete URLs
- Admin - users, organizations, customers, summary
- AI - streaming chat endpoint
- Auth - Better Auth routes via the dedicated OpenAPI plugin
Intentionally excluded from the API document: billing webhooks, the auth catch-all proxy, the status monitor, and the /openapi + /docs routes themselves. Those are infrastructure, not product surface.
Architecture
packages/api
├── src/lib/openapi.ts # document() + shared error responses
├── src/schema/openapi.ts # ApiError envelope (Zod)
├── src/modules/*/router.ts # doc({ summary, response }) per route
└── src/index.ts # /openapi + /docs handlers
packages/auth
└── src/server.ts # openAPI() Better Auth pluginFlow:
- Routes keep using Zod via
validate()for request bodies and query params. document()attaches OpenAPI metadata (tags, summary, security, response schemas).openAPIRouteHandlerwalks the Hono app and emits OpenAPI JSON.- Scalar loads that JSON (plus Better Auth's schema) and renders
/api/docs.
First-party apps keep calling through the typed RPC client. OpenAPI is an additional export, not a replacement.
Setup
If your TurboStarter already includes OpenAPI, skip to Documenting a route. Otherwise, add the pieces below.
Install packages
Add the OpenAPI stack to @workspace/api. Prefer whatever versions hono-openapi and @scalar/hono-api-reference recommend for your current Hono release - peer dependencies can change between majors:
pnpm --filter @workspace/api add hono-openapi @scalar/hono-api-referenceInstall any additional packages those libraries list as peers (for example standard-schema / OpenAPI helpers). Then switch request validation to hono-openapi's validator so Zod schemas feed both runtime checks and the document. You can remove @hono/zod-validator once that migration is done.
Enable Better Auth OpenAPI
Register the official plugin so auth endpoints get their own schema:
import { openAPI } from "better-auth/plugins";
export const auth = betterAuth({
// ...existing config
plugins: [
// ...existing plugins
openAPI(),
],
});Better Auth then exposes an OpenAPI schema endpoint (check the Better Auth OpenAPI plugin docs for the exact path in your version), which Scalar will load as a second source next to your Hono API document.
Add shared OpenAPI helpers
Create a small error envelope and a document() factory. Every module reuses the same 401 / 403 / 422 responses and cookie security scheme:
import * as z from "zod";
export const apiErrorSchema = z.object({
code: z.string().optional(),
message: z.string(),
status: z.number(),
timestamp: z.string(),
path: z.string(),
});
export type ApiError = z.infer<typeof apiErrorSchema>;import { describeRoute, resolver } from "hono-openapi";
import { apiErrorSchema } from "../schema/openapi";
import type { DescribeRouteOptions } from "hono-openapi";
import type * as z from "zod";
export const jsonResponse = <T extends z.ZodType>(
schema: T,
description = "OK",
) => ({
description,
content: {
"application/json": {
schema: resolver(schema),
},
},
});
export const errorResponses: NonNullable<DescribeRouteOptions["responses"]> = {
401: jsonResponse(apiErrorSchema, "Unauthorized"),
403: jsonResponse(apiErrorSchema, "Forbidden"),
422: jsonResponse(apiErrorSchema, "Validation error"),
};
export const document =
(tag: string) =>
<T extends z.ZodType>({
summary,
response,
responses,
security = [{ cookieAuth: [] }],
...options
}: {
summary: string;
response?: T;
} & DescribeRouteOptions) =>
describeRoute({
...options,
tags: [tag],
summary,
security,
responses: {
...errorResponses,
...(response ? { 200: jsonResponse(response) } : undefined),
...responses,
},
});One helper, every module
document("Organizations") (or "Billing", "Admin", …) returns a middleware factory. Keep call sites boring: doc({ summary, response }) and move on.
Switch validation to hono-openapi
Update validate so request schemas are part of the OpenAPI document:
import { validator } from "hono-openapi";
export const validate = <
T extends z.ZodType,
Target extends keyof ValidationTargets,
>(
target: Target,
schema: T,
) =>
validator(target, schema, async (result, c) => {
if (result.success) {
return;
}
// ...existing i18n error mapping
throw new HttpException(HttpStatusCode.UNPROCESSABLE_ENTITY, {
code,
message,
});
});Behavior for clients stays the same - invalid input still returns 422 with your localized error envelope.
Mount /openapi and /docs
Register the document handler and Scalar UI on the main app router (after your feature routes, before onError):
import { Scalar } from "@scalar/hono-api-reference";
import { openAPIRouteHandler } from "hono-openapi";
const appRouter = app
.get(
"/openapi",
openAPIRouteHandler(app, {
documentation: {
info: {
title: "TurboStarter API",
version: "1.0.0", // bump when you ship breaking API changes
},
servers: [{ url: "/" }],
tags: [
{ name: "Admin" },
{ name: "AI" },
{ name: "Billing" },
{ name: "Organizations" },
{ name: "Storage" },
],
components: {
securitySchemes: {
cookieAuth: {
type: "apiKey",
in: "cookie",
// match your Better Auth session cookie name
name: "turbostarter.session_token",
},
},
},
},
exclude: [/^\/openapi$/, /^\/docs$/],
}),
)
.get(
"/docs",
Scalar({
pageTitle: "TurboStarter API",
sources: [
{ url: "/api/openapi", title: "API" },
// Better Auth OpenAPI schema URL for your setup
{ url: "/api/auth/open-api/generate-schema", title: "Auth" },
],
}),
)
.onError(onError);With the web app running, open:
- http://localhost:3000/api/docs - Scalar UI

- http://localhost:3000/api/openapi - raw OpenAPI JSON

Documenting a route
Annotating a route is a three-line habit: response Zod schema, doc({ summary, response }), keep validate for inputs.
Define response schemas
Put response shapes next to your input schemas so the contract lives with the module:
export const generateSlugInputSchema = z.object({
name: z.string(),
});
export const generateSlugResponseSchema = z.object({
slug: z.string(),
});
export const getMembersResponseSchema = z.object({
data: z.array(
z.object({
id: z.string(),
organizationId: z.string(),
role: z.enum(MemberRole),
createdAt: z.coerce.date(),
userId: z.string(),
user: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
image: z
.string()
.nullish()
.transform((val) => (val === null ? undefined : val)),
}),
}),
),
total: z.number(),
});Attach document() to the handler
import { document } from "../../lib/openapi";
import { enforceAuth, validate } from "../../middleware";
import {
generateSlugInputSchema,
generateSlugResponseSchema,
getMembersInputSchema,
getMembersResponseSchema,
} from "../../schema";
const doc = document("Organizations");
export const organizationRouter = new Hono()
.use(enforceAuth)
.get(
"/slug",
doc({
summary: "Generate organization slug",
response: generateSlugResponseSchema,
}),
validate("query", generateSlugInputSchema),
async (c) => c.json(await generateSlug(c.req.valid("query").name)),
)
.get(
"/:id/members",
doc({
summary: "List organization members",
response: getMembersResponseSchema,
}),
validate("query", getMembersInputSchema),
(c, next) =>
enforceMembership({ organizationId: c.req.param("id") })(c, next),
async (c) =>
c.json(
await getMembers({
organizationId: c.req.param("id"),
...c.req.valid("query"),
}),
),
);Override security or content type when needed
Public routes should clear the default cookie security. Streaming or non-JSON responses use a custom responses map:
.get(
"/public",
doc({
summary: "Get public URL",
response: storageUrlResponseSchema,
security: [], // public - no session cookie required
}),
validate("query", getObjectUrlSchema),
async (c) => c.json(await getPublicUrl(c.req.valid("query"))),
);import { resolver } from "hono-openapi";
.post(
"/chat",
doc({
summary: "Stream AI chat",
responses: {
200: {
description: "UI message stream",
content: {
"text/plain": { schema: resolver(z.string()) },
},
},
},
}),
enforceAuth,
async (c) => {
/* ... */
},
);Don't document everything
Skip webhooks, health checks, and internal proxies. If a route is not meant for humans or agents to call, leave document() off - it won't appear in /api/openapi.
Using the docs day to day
- Start the web app (
pnpm --filter web devorpnpm dev). - Open
/api/docsand switch between the API and Auth sources. - Sign in to the app in another tab so Scalar's "Try it" requests carry the session cookie.
- Spot-check a few operations (organizations, billing, storage) and confirm request/response schemas match reality.
- Hit
/api/openapiwhen you need the raw document for codegen or an MCP tool.

AI agents and MCP
OpenAPI is the cheapest way to make your product API agent-ready.
- Point an agent at
/api/openapi- many agent frameworks can load an OpenAPI document and turn each operation into a tool. - Build an MCP server from the document - generate or hand-write MCP tools that wrap authenticated Hono routes. Agents in Cursor, Claude, or ChatGPT then talk to your SaaS the same way they talk to third-party APIs.
- Keep schemas strict - Zod on input and output is what makes tool calling reliable. Vague
z.any()responses produce vague agent behavior. - Separate product API from admin API - publish a public subset for customer agents; keep Admin tagged and gated behind stronger auth.
Example agent prompt once docs are live:
Load https://your-app.com/api/openapi and list organization members for org_123.
Use the session cookie from my browser / the provided API token.Pair this with TurboStarter's docs MCP server for documentation, and your own OpenAPI-backed MCP for live product actions - docs for knowledge, OpenAPI for execution.
Scaling the setup
The base integration is intentionally thin. Grow it as your API surface grows.
Codegen SDKs
Export /api/openapi in CI and generate clients for languages outside the monorepo:
# example - generate a typed JS/TS client from the live document
npx openapi-typescript https://your-app.com/api/openapi -o src/generated/api.tsThe same document works with OpenAPI Generator and other SDK pipelines - pick whatever fits your stack.
Versioning and environments
- Bump
info.versionwhen you ship breaking response changes. - Add multiple
serversentries (local, staging, production) so Scalar and codegen pick the right base URL. - For multi-tenant or white-label APIs, generate the document per deployment with environment-specific
serversandinfo.title.
Split public vs private documents
When you expose partner APIs, mount a second handler that only includes tagged public routes:
openAPIRouteHandler(app, {
documentation: {
info: {
title: "TurboStarter Public API",
version: "1.0.0", // your public API version
},
// ...
},
exclude: [
/^\/openapi$/,
/^\/docs$/,
/^\/admin(\/|$)/, // hide admin from the public document
],
});Serve /api/openapi privately and /api/public/openapi on a partner portal.
Contract testing
Treat the OpenAPI JSON as an artifact:
- Snapshot
/api/openapiin CI and fail on unexpected diffs - Validate responses against response Zod schemas in integration tests (you already have the schemas)
- Generate smoke tests for every
200operation before a release
Gateways and edge
Import the document into your API gateway for rate limits, auth policies, and request validation at the edge - without re-describing routes a third time.
Error envelope consistency
Align runtime errors with apiErrorSchema (including timestamp) so documented error responses match what clients actually receive. Agents and generated SDKs rely on that consistency.
Checklist
When you add a new Hono module:
- Add input and response Zod schemas under
packages/api/src/schema - Create
const doc = document("YourTag")in the router - Call
doc({ summary, response })on every product-facing route - Use
security: []for public routes - Leave webhooks / health / internal proxies undocumented
- Confirm the route appears under the right tag in
/api/docs - Smoke the operation with Scalar "Try it" while authenticated
How is this guide?
Last updated on