| title | OpenAPI |
|---|---|
| description | Generate OpenAPI 3.1 docs from your Zod schemas. |
| icon | file-json |
Spiceflow generates a full OpenAPI 3.1 document from your routes without you writing a single line of schema by hand. The openapi plugin walks every registered route and uses the Zod schemas you already passed to request, query, params, and response to produce the paths, parameters, requestBody, and responses sections for you.
There is nothing "magical" or decorator-based here — the same Zod schemas that validate the request at runtime and give you a typed handler context are also the input for the OpenAPI output.
Mount the plugin with .use(openapi({ path: '/openapi.json' })) and every other route registered on the app (before or after the plugin) is picked up automatically when a client requests that path:
import { Spiceflow } from 'spiceflow'
import { openapi } from 'spiceflow/openapi'
import { z } from 'zod'
export const app = new Spiceflow()
.use(
openapi({
path: '/openapi.json',
info: {
title: 'My API',
version: '1.0.0',
},
}),
)
.route({
method: 'GET',
path: '/users/:id',
params: z.object({ id: z.string() }),
response: z.object({
id: z.string(),
name: z.string(),
}),
handler({ params }) {
return { id: params.id, name: 'Alice' }
},
})
const schema = await app
.handle(new Request('http://localhost/openapi.json'))
.then((res) => res.json())The returned document is a standard OpenAPIV3.Document you can hand off to Swagger UI, Redoc, Fern, Stainless, or any other OpenAPI tool.
The preferred way to describe a route is to pass Zod schemas for every piece of input. This gives you three things at once with zero duplication:
- Runtime validation — requests with a bad body, query, or params are rejected with a
400. - Type-safe handler context —
request.json(),query, andparamsare typed from the same schemas. - OpenAPI output —
requestBody,parameters, andresponsesare emitted directly from the schemas.
import { Spiceflow } from 'spiceflow'
import { openapi } from 'spiceflow/openapi'
import { z } from 'zod'
export const app = new Spiceflow()
.use(openapi({ path: '/openapi.json' }))
.route({
method: 'POST',
path: '/users/:orgId',
params: z.object({ orgId: z.string() }),
query: z.object({
notify: z.boolean().optional(),
}),
request: z.object({
name: z.string(),
email: z.string().email(),
}),
response: z.object({
id: z.string(),
name: z.string(),
email: z.string(),
}),
async handler({ params, query, request }) {
// params is typed as { orgId: string }
// query is typed as { notify?: boolean }
// body is typed as { name: string, email: string }
const body = await request.json()
return {
id: 'usr_123',
name: body.name,
email: body.email,
}
},
})Always prefer
.route()over.get()/.post()for public APIs. The.route()form takes every schema key (request,response,query,params,detail) in a single object, which is easier to discover and keeps the schemas attached to the handler where they belong. Use.get()/.post()only for quick internal routes that do not need validation or OpenAPI.
When a route can return different payloads depending on the status code, pass response as an object keyed by HTTP status codes instead of a single schema. Each entry becomes its own entry in the emitted OpenAPI responses object:
import { z } from 'zod'
import { Spiceflow, json } from 'spiceflow'
import { openapi } from 'spiceflow/openapi'
const ErrorShape = z.object({
error: z.string(),
code: z.string(),
})
export const app = new Spiceflow()
.use(openapi({ path: '/openapi.json' }))
.route({
method: 'GET',
path: '/users/:id',
params: z.object({ id: z.string() }),
response: {
200: z.object({
id: z.string(),
name: z.string(),
}),
404: ErrorShape,
500: ErrorShape,
},
handler({ params }) {
const user = findUser(params.id)
if (!user) {
throw json(
{ error: 'not found', code: 'NOT_FOUND' },
{ status: 404 },
)
}
return { id: user.id, name: user.name }
},
})json(body, init) is a type-safe wrapper around Response.json() — it sets content-type: application/json automatically and carries the data type and status code through the type system. Prefer json() over Response.json() so the fetch client gets typed error responses.
Use the status-code map whenever you already know which non-2xx status codes your route can return and there is a realistic possibility of errors. Consumers of your API — and tools like Fern or Stainless that generate SDKs from the OpenAPI document — get precise types for each failure mode instead of a generic "it might fail".
The client side also benefits: createSpiceflowFetch reads this map to produce a discriminated error type per status code. See Preserving Client Type Safety below.
In most real APIs the same handful of error shapes show up on every route — usually a 500 for unexpected failures, a 401 for missing auth, maybe a 400 for validation. Spiceflow has a centralized onError handler that runs for every thrown error, which pairs naturally with a shared response object you spread into each route.
Define the error schema and the corresponding response entry in one module, then spread it into every route:
// src/api/shared-responses.ts
import { z } from 'zod'
export const ErrorResponse = z.object({
error: z.string(),
code: z.string(),
requestId: z.string().optional(),
})
// Shared response entries that every route should document.
export const commonResponses = {
500: ErrorResponse,
401: ErrorResponse,
} as const// src/api/app.ts
import { Spiceflow, json } from 'spiceflow'
import { openapi } from 'spiceflow/openapi'
import { z } from 'zod'
import { commonResponses, ErrorResponse } from './shared-responses'
export const app = new Spiceflow()
.use(openapi({ path: '/openapi.json' }))
.onError(({ error, request }) => {
console.error('[api]', request.url, error)
return json(
{
error: error.message || 'internal server error',
code: 'INTERNAL',
},
{ status: 500 },
)
})
.route({
method: 'GET',
path: '/users/:id',
params: z.object({ id: z.string() }),
response: {
...commonResponses,
200: z.object({ id: z.string(), name: z.string() }),
404: ErrorResponse,
},
handler({ params }) {
const user = findUser(params.id)
if (!user) {
throw json(
{ error: 'not found', code: 'NOT_FOUND' },
{ status: 404 },
)
}
return user
},
})
.route({
method: 'POST',
path: '/users',
request: z.object({ name: z.string(), email: z.string().email() }),
response: {
...commonResponses,
200: z.object({ id: z.string() }),
},
async handler({ request }) {
const body = await request.json()
return { id: 'usr_' + body.name }
},
})Every route now documents the shared 500 and 401 responses without duplicating the schema, and the actual 500 body is produced by a single onError handler at the app root. If you later change the error shape, you only edit shared-responses.ts.
Spread
...commonResponsesfirst, then list route-specific entries. Later keys win in object spread, so putting the shared responses at the top lets an individual route override, say, the shared500with a more specific schema if it ever needs to — and leaves the route-specific entries like200and404visually grouped at the bottom.
When the same type appears in more than one route — User, Organization, PaginatedList<T> — define the Zod schema once in a dedicated file and import it. Do not inline the same object in multiple routes; besides the duplication, you lose the ability to rename a field in one place.
// src/schemas.ts
import { z } from 'zod'
export const User = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
})
export type User = z.infer<typeof User>
export const UserList = z.object({
items: z.array(User),
nextCursor: z.string().nullable(),
})// src/api/users.ts
import { Spiceflow } from 'spiceflow'
import { z } from 'zod'
import { User, UserList } from '../schemas'
import { commonResponses } from './shared-responses'
export const usersApi = new Spiceflow({ basePath: '/users' })
.route({
method: 'GET',
path: '/',
query: z.object({ cursor: z.string().optional() }),
response: { ...commonResponses, 200: UserList },
handler: () => ({ items: [], nextCursor: null }),
})
.route({
method: 'GET',
path: '/:id',
params: z.object({ id: z.string() }),
response: { ...commonResponses, 200: User },
handler: ({ params }) => ({
id: params.id,
name: 'Alice',
email: 'alice@example.com',
createdAt: new Date().toISOString(),
}),
})Any schema that appears in more than one route should live in a single src/schemas.ts file (or wherever you keep domain types) and be imported everywhere it is used. Keep it as one flat file until it actually grows too large — a single schemas.ts is easier to navigate than a schemas/ folder with one file per type.
Pass detail: { hide: true } on any route you do not want to appear in the generated OpenAPI document. This is useful for internal health checks, debug endpoints, and routes that exist purely for server-to-server communication:
import { Spiceflow } from 'spiceflow'
import { openapi } from 'spiceflow/openapi'
export const app = new Spiceflow()
.use(openapi({ path: '/openapi.json' }))
.route({
method: 'GET',
path: '/health',
detail: { hide: true },
handler: () => ({ ok: true }),
})
.route({
method: 'POST',
path: '/internal/reindex',
detail: { hide: true },
handler: () => ({ queued: true }),
})Hidden routes still work normally at runtime — they just do not show up in the emitted schema.
The detail field accepts anything from the OpenAPI operation object, so you can attach summary, description, tags, operationId, and any vendor extension. For anything longer than a single line, write the description as a real multi-line string using the string-dedent package so your code stays readable and the leading indentation is stripped automatically.
import { Spiceflow } from 'spiceflow'
import { openapi } from 'spiceflow/openapi'
import { z } from 'zod'
import dedent from 'string-dedent'
import { User } from '../schemas'
import { commonResponses } from './shared-responses'
export const app = new Spiceflow()
.use(openapi({ path: '/openapi.json' }))
.route({
method: 'POST',
path: '/users',
request: z.object({
name: z.string(),
email: z.string().email(),
}),
response: { ...commonResponses, 200: User },
detail: {
summary: 'Create a user',
tags: ['users'],
description: dedent`
Creates a new user in the current organization.
## Behavior
- The \`email\` field must be unique across the organization.
If it already exists, the endpoint returns \`409 Conflict\`.
- The returned \`id\` is a stable prefix-encoded identifier
(\`usr_\` prefix + 24 base32 characters).
## Example
\`\`\`bash
curl -X POST https://api.example.com/users \\
-H 'content-type: application/json' \\
-d '{ "name": "Alice", "email": "alice@example.com" }'
\`\`\`
`,
},
async handler({ request }) {
const body = await request.json()
return {
id: 'usr_abc',
name: body.name,
email: body.email,
createdAt: new Date().toISOString(),
}
},
})Markdown in the description is rendered by Swagger UI, Redoc, Scalar, Fern, and Stainless, so you can include headings, bullets, inline code, and fenced code blocks.
You often want a physical openapi.json file checked into the repo — for SDK generation, for docs sites, or for diffing the schema on every PR. The cleanest way to produce one is to call app.handle() directly instead of spinning up a real HTTP server:
// scripts/generate-openapi.ts
import { writeFile } from 'node:fs/promises'
import { app } from '../src/api/app'
const response = await app.handle(
new Request('http://localhost/openapi.json'),
)
const schema = await response.json()
await writeFile('openapi.json', JSON.stringify(schema, null, 2))
console.log('Wrote openapi.json')Run it with any TypeScript runner:
tsx scripts/generate-openapi.ts
# or
bun scripts/generate-openapi.tsWire this into your build so the file is always in sync with the code:
{
"scripts": {
"openapi": "tsx scripts/generate-openapi.ts",
"prebuild": "pnpm openapi"
}
}If you prefer a type-safe call, use createSpiceflowFetch against the app instance (no real network, no server) — see the fetch client docs for a worked example.
The type-safe fetch client reads your route schemas the same way the OpenAPI plugin does, so the way you declare response directly affects what the client sees.
Rule of thumb: success responses should be returned from the handler, and non-2xx responses should be thrown using json(). Use json() from spiceflow instead of Response.json() — it preserves the data type and status code in the type system, so TypeScript validates that the status exists in the response schema and the body shape matches. Response.json() erases all type information.
When you throw non-successful responses, the client inference stays clean: the happy-path return type is just Data, and errors are delivered as a SpiceflowFetchError that instanceof Error narrows away.
// src/api/app.ts
import { Spiceflow, json } from 'spiceflow'
import { z } from 'zod'
const NotFound = z.object({ error: z.literal('not found') })
const Forbidden = z.object({ error: z.literal('forbidden'), reason: z.string() })
export const app = new Spiceflow().route({
method: 'GET',
path: '/users/:id',
params: z.object({ id: z.string() }),
response: {
200: z.object({ id: z.string(), name: z.string() }),
403: Forbidden,
404: NotFound,
},
handler({ params }) {
if (params.id === 'banned') {
throw json(
{ error: 'forbidden', reason: 'account suspended' },
{ status: 403 },
)
}
const user = findUser(params.id)
if (!user) {
throw json({ error: 'not found' }, { status: 404 })
}
// Returned directly — the fetch client will type this as the success case only.
return { id: user.id, name: user.name }
},
})
export type App = typeof appOn the client side, the fetch client gives you back a discriminated Error | Data union. The error half is typed from the status-code response map: for each non-200 status you defined, the client gets a SpiceflowFetchError<status, body> with the exact body shape that status returns.
// src/client.ts
import { createSpiceflowFetch } from 'spiceflow/client'
const api = createSpiceflowFetch('https://api.example.com')
const result = await api('/users/:id', { params: { id: 'abc' } })
if (result instanceof Error) {
// result is typed as
// | SpiceflowFetchError<403, { error: 'forbidden'; reason: string }>
// | SpiceflowFetchError<404, { error: 'not found' }>
switch (result.status) {
case 403:
// result.value is { error: 'forbidden'; reason: string }
console.error('Forbidden:', result.value.reason)
break
case 404:
// result.value is { error: 'not found' }
console.error('User not found')
break
}
return
}
// result is typed as { id: string; name: string } — no error shape leaked in
console.log('User:', result.id, result.name)Three things fall out of this pattern:
- The success branch is clean.
result.idandresult.nameare available without null checks or type narrowing on error fields. - Every error status is exhaustively typed. If you add a new status to the route's
responsemap, TypeScript forces you to handle it on the client. - The OpenAPI document is accurate. The same map that drives the client types also drives the
responsesobject inopenapi.json, so your SDK consumers see exactly the same contract.
If a route has no documented non-2xx responses at all, the client falls back to a generic SpiceflowFetchError<number, any> for the error branch — which still works, but loses per-status typing. Add status entries to response whenever you care about the failure shape.