[0], // required, no ?
options?: ...
)
```
## Error handling
[Section titled “Error handling”](#error-handling)
Every generated hook types errors as `ApiError`. This class is generated by `openapi-zod-ts` into your `client.ts` output file:
```ts
export class ApiError extends Error {
constructor(
public readonly status: number, // HTTP status code, e.g. 422
public readonly body: unknown, // parsed JSON response body (or null)
) { ... }
}
```
### Handling errors in query hooks
[Section titled “Handling errors in query hooks”](#handling-errors-in-query-hooks)
```tsx
const { data, isError, error } = useGetTask(taskId)
if (isError) {
// error is typed ApiError
console.error(error.status, error.body)
return Failed to load task ({error.status})
}
```
### Handling errors in mutation hooks
[Section titled “Handling errors in mutation hooks”](#handling-errors-in-mutation-hooks)
Pass an `onError` callback in `options`:
```tsx
const create = useCreateTask({
onError: (err) => {
// err is typed ApiError
console.error('Status:', err.status)
console.error('Body:', err.body)
},
})
```
### Connecting to `@codewithagents/api-errors`
[Section titled “Connecting to @codewithagents/api-errors”](#connecting-to-codewithagentsapi-errors)
`ApiError` is the direct input to [`@codewithagents/api-errors`](/api-errors), which maps backend validation errors to form field errors. Mutation errors are already typed correctly:
```ts
import { extractFieldErrors } from '@codewithagents/api-errors'
const create = useCreateTask({
onError: (err) => {
// err is ApiError; pass directly to extractFieldErrors
const fieldErrors = extractFieldErrors(err, { statusCodes: [422] })
fieldErrors.forEach(({ field, message }) => form.setError(field, { message }))
},
})
```
## Testing your hooks
[Section titled “Testing your hooks”](#testing-your-hooks)
The generator produces `test-utils.ts` alongside `hooks.ts` to remove the boilerplate every hook test needs: a pre-configured `QueryClient` and a `QueryClientProvider` wrapper component. You import directly from the generated file, so there are no extra packages to install beyond your test runner and any mocking library you already use.
| Export | What it does |
| ---------------------------- | --------------------------------------------------------------------------------------------------------- |
| `createTestQueryClient()` | Returns a `QueryClient` with `retry: false` and `gcTime: 0`, the right defaults for deterministic tests |
| `createWrapper(queryClient)` | Returns a React component suitable for the `wrapper` option in `renderHook` from `@testing-library/react` |
### Query hook test
[Section titled “Query hook test”](#query-hook-test)
The example below uses [Vitest](https://vitest.dev/), [React Testing Library](https://testing-library.com/docs/react-testing-library/api/#renderhook), and [MSW](https://mswjs.io/) for HTTP mocking. Only the last two are new additions if you are not already using them.
Tip
The MSW handlers below are hand-written for one endpoint, which is fine for a focused test. To back a whole hook suite, [`@codewithagents/openapi-msw`](/openapi-msw) generates MSW v2 handlers with seeded Faker data from the same OpenAPI spec, so your mocks stay in sync with the contract the hooks are derived from.
src/tasks/\_\_tests\_\_/useListTasks.test.ts
```ts
import { renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { createTestQueryClient, createWrapper } from '../api/test-utils'
import { useListTasks } from '../api/hooks'
import { configureClient } from '../api/client-config'
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
beforeEach(() => configureClient({ baseUrl: 'https://api.example.com' }))
it('returns tasks on success', async () => {
server.use(
http.get('https://api.example.com/api/v1/tasks', () =>
HttpResponse.json({
items: [{ id: '1', title: 'Ship it', status: 'pending' }],
total: 1,
})
)
)
const queryClient = createTestQueryClient()
const wrapper = createWrapper(queryClient)
const { result } = renderHook(() => useListTasks(), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.items).toHaveLength(1)
expect(result.current.data?.items[0]?.title).toBe('Ship it')
})
```
### Mutation hook test
[Section titled “Mutation hook test”](#mutation-hook-test)
src/tasks/\_\_tests\_\_/useCreateTask.test.ts
```ts
import { act, renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { createTestQueryClient, createWrapper } from '../api/test-utils'
import { useCreateTask } from '../api/hooks'
import { configureClient } from '../api/client-config'
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
beforeEach(() => configureClient({ baseUrl: 'https://api.example.com' }))
it('creates a task and returns the new record', async () => {
const created = { id: 'new-1', title: 'New task', status: 'pending' }
server.use(
http.post('https://api.example.com/api/v1/tasks', () =>
HttpResponse.json(created, { status: 201 })
)
)
const queryClient = createTestQueryClient()
const wrapper = createWrapper(queryClient)
const { result } = renderHook(() => useCreateTask(), { wrapper })
await act(async () => {
result.current.mutate({ title: 'New task' })
})
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(result.current.data?.id).toBe('new-1')
})
```
Tip
Create a fresh `queryClient` per test using `createTestQueryClient()`. Sharing one instance across tests causes stale-cache contamination and can produce flaky results.
## SSR and Next.js App Router
[Section titled “SSR and Next.js App Router”](#ssr-and-nextjs-app-router)
The generated hooks are **client-side only**. They call React Query’s `useQuery` and `useMutation`, which require a browser context and a `QueryClientProvider` in the component tree. They will not work in React Server Components.
For SSR prefetching, use the exported key factories together with React Query’s standard `HydrationBoundary` approach:
```tsx
// app/tasks/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { listTasks } from '@/api/client'
import { taskKeys } from '@/api/hooks'
import TaskList from './TaskList'
export default async function TasksPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: taskKeys.list(),
queryFn: () => listTasks(),
})
// If the endpoint has required query params, pass them to both taskKeys.list(params) and listTasks(params).
return (
)
}
```
```tsx
// app/tasks/TaskList.tsx ('use client')
'use client'
import { useListTasks } from '@/api/hooks'
export default function TaskList() {
const { data } = useListTasks()
return (
{data?.items.map((t) => (
- {t.title}
))}
)
}
```
The key factories ensure that the prefetched server data matches the key that the client-side hook will look up. See the [TanStack Query SSR docs](https://tanstack.com/query/v5/docs/framework/react/guides/advanced-ssr) for the full setup.
## Deprecated operations
[Section titled “Deprecated operations”](#deprecated-operations)
When your spec marks an operation as `deprecated: true`, the generated hook gets a `/** @deprecated */` JSDoc comment. TypeScript and most IDEs will show a strikethrough at the call site:
```ts
/** @deprecated */
export function useGetLegacyTask(id: Parameters[0] | undefined | null, options?: ...) { ... }
```
## Works with
[Section titled “Works with”](#works-with)
Types and fetch client
`openapi-zod-ts` generates the typed fetch client and models that these hooks wrap. Run it first.
[Learn more](/openapi-zod-ts)
Server interface
`@codewithagents/openapi-server` generates a typed service interface and optional router (default: none) for Fastify, Hono, or Express from the same spec.
[Learn more](/openapi-server)
MSW mock handlers
`@codewithagents/openapi-msw` generates seeded MSW v2 handlers from the same spec to back the hook tests on this page.
[Learn more](/openapi-msw)
Form error mapping
`@codewithagents/api-errors` maps `ApiError` responses from mutation hooks directly to form field errors.
[Learn more](/api-errors)
## Troubleshooting
[Section titled “Troubleshooting”](#troubleshooting)
### Cache not invalidating after a mutation
[Section titled “Cache not invalidating after a mutation”](#cache-not-invalidating-after-a-mutation)
Check two things:
1. `auto_invalidate` must be set to `true` in your config file.
2. Auto-invalidation only fires for resources that have at least one GET operation in the spec. A mutation-only resource has no key factory, so no invalidation is generated.
### Detail hook fires even with a nullish id
[Section titled “Detail hook fires even with a nullish id”](#detail-hook-fires-even-with-a-nullish-id)
The generated hook emits `enabled: id != null && (options?.enabled ?? true)` and then `...options`. Because `...options` is spread after the built-in `enabled` line, any `enabled` key you pass inside `options` overwrites the entire guard expression via the spread. Omit `enabled` from `options` (or pass `enabled: undefined`) to keep the null guard in effect.
### Hooks do not work in React Server Components
[Section titled “Hooks do not work in React Server Components”](#hooks-do-not-work-in-react-server-components)
All generated hooks use `useQuery` or `useMutation` internally and require a browser context plus a `QueryClientProvider` ancestor. Add `'use client'` to the component file, or prefetch on the server using the key factories and `HydrationBoundary` (see [SSR and Next.js App Router](#ssr-and-nextjs-app-router) above).
# Server interface
> Generate a typed service interface and optional Fastify, Hono, or Express router from your OpenAPI spec with @codewithagents/openapi-server.
`@codewithagents/openapi-server` reads your OpenAPI 3.x spec and writes a typed service interface into your project. The interface is plain TypeScript with no framework imports: implement it however you want and wire it to Fastify, Hono, Express, or any router you already use. Set `"framework"` to `"fastify"`, `"hono"`, or `"express"` (default `"none"`) and the generator also scaffolds a ready-to-mount router, with optional Zod validation at the boundary so invalid requests never reach your service implementation. Fastify is the framework the project ships its canonical full-stack app on; the Hono and Express routers are first-class alternatives.
## Overview
[Section titled “Overview”](#overview)
Running the generator produces up to two files in your output directory:
| File | Contents |
| ------------ | ----------------------------------------------------------------------------------------------- |
| `service.ts` | TypeScript interface with one typed `async` method per API operation. No framework imports. |
| `router.ts` | Router factory, generated only when `framework` is set to `"hono"`, `"express"`, or `"fastify"` |
Key guarantees:
* **Framework-agnostic service interface.** `service.ts` has zero framework dependencies. The TypeScript compiler enforces the contract: add an endpoint in the spec and forget to implement it, and the build fails.
* **Optional router scaffolding.** Choose `"fastify"`, `"hono"`, or `"express"` to get a ready-to-mount router as a starting point. Use `"none"` (the default) to wire the interface yourself with any framework.
* **Supports GET, POST, PUT, PATCH, DELETE.** All five HTTP methods are generated when present in the spec.
* **Zod validation at the boundary.** Point `input_schema` at the same Zod schema file you use with `openapi-zod-ts`. Hono and Express get `safeParse` calls that return a structured `422` before the call ever reaches your service. Fastify wires the same schemas through `fastify-type-provider-zod` and rejects invalid requests with its native `400` (`FST_ERR_VALIDATION`).
* **Prettier-clean output.** Every generated file passes `prettier --check` with your project’s own Prettier config.
* **`strict: true`.** All output compiles cleanly with TypeScript strict mode.
* **OpenAPI 3.1.x** is the primary target (including 3.1.1). OpenAPI 3.0.x is supported in practice: 8 of the 13 showcase specs are 3.0.x and all compile under `tsc --strict`. Full support for `$ref`, `allOf`, `anyOf`, `oneOf`, and `nullable`.
## Install
[Section titled “Install”](#install)
* npm
```sh
npm i -D @codewithagents/openapi-server
```
* pnpm
```sh
pnpm add -D @codewithagents/openapi-server
```
* yarn
```sh
yarn add -D @codewithagents/openapi-server
```
Note
`@codewithagents/openapi-server` requires `openapi-zod-ts`. Run `openapi-zod-ts` first so `models.ts` exists before `service.ts` imports from it.
Note
The generated service examples use `node:crypto` (`randomUUID`). Node.js 18 or later is required.
## Minimal example
[Section titled “Minimal example”](#minimal-example)
**1. Create `openapi-server.config.json` in your project root:**
```json
{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "hono"
}
```
**2. Run both generators:**
```bash
npx openapi-zod-ts && npx openapi-server
```
Or add a combined script to `package.json`:
```json
{
"scripts": {
"generate": "openapi-zod-ts && openapi-server"
}
}
```
**3. Implement the generated service interface:**
src/server/petService.ts
```typescript
import { randomUUID } from 'node:crypto'
import type { PetstoreService } from '../generated/service.js'
import type { Pet } from '../generated/models.js'
const pets = new Map()
export const petService: PetstoreService = {
async listPets(params) {
const all = Array.from(pets.values())
if (params?.species) {
return all.filter((p) => p.species.toLowerCase() === params.species!.toLowerCase())
}
return all
},
async createPet(body) {
const pet: Pet = { id: randomUUID(), ...body }
pets.set(pet.id, pet)
return pet
},
async getPet(id) {
const pet = pets.get(id)
if (!pet) throw new Error(`Pet ${id} not found`)
return pet
},
async deletePet(id) {
pets.delete(id)
},
}
```
**4. Mount the generated router and serve:**
src/server/index.ts
```typescript
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { createRouter } from '../generated/router.js'
import { petService } from './petService.js'
const app = new Hono()
// Mount API routes at /api
const apiRouter = createRouter(petService)
app.route('/api', apiRouter)
serve({ fetch: app.fetch, port: 3001 })
```
## Configuration
[Section titled “Configuration”](#configuration)
The config file must be valid JSON and must have a `.json` extension. By default the generator looks for `openapi-server.config.json` in the current working directory. Use `--config ` to point at a different file.
```bash
npx openapi-server --config ./config/openapi-server.config.json
```
Note
When `--config` is used, all relative paths inside the file resolve from the config file’s directory, not from the shell’s working directory.
### Options
[Section titled “Options”](#options)
| Field | Type | Required | Default | Description |
| -------------------------- | -------------------------------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `input_openapi` | `string` | Yes | n/a | Path to your OpenAPI spec (JSON or YAML) |
| `output` | `string` | Yes | n/a | Directory to write the generated files |
| `framework` | `"hono" \| "express" \| "fastify" \| "none"` | No | `"none"` | Router framework to generate. Use `"none"` to generate only `service.ts` |
| `input_schema` | `string` | No | n/a | Path to user-owned Zod schema file. Enables server-side request validation (never overwritten) |
| `context_type` | `string` | No | n/a | TypeScript type name threaded through every service method as a final `ctx` argument. On Fastify this becomes a required `createContext(req)` auth seam. See [request-scoped context](#request-scoped-context-context_type). |
| `emit_response_validation` | `boolean` | No | `false` | Fastify only. Synthesize inline Zod response schemas for flat inline response shapes. See [response validation](#response-validation-opt-in-emit_response_validation-fastify-only). |
| `shared_output` | `string` | No | derived | Override directory for the shared `_shared/errors.ts` file. Defaults to a location derived from the output paths. |
**Full config example:**
```json
{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "hono",
"input_schema": "./generated/schemas.ts"
}
```
## Generated files in depth
[Section titled “Generated files in depth”](#generated-files-in-depth)
### `service.ts`
[Section titled “service.ts”](#servicets)
Given a petstore spec (`GET /pets`, `POST /pets`, `GET /pets/{id}`, `DELETE /pets/{id}`), the generator produces a plain TypeScript interface with no framework dependencies:
```typescript
// generated/service.ts (auto-generated)
import type { CreatePetRequest, Pet } from './models.js'
export interface PetstoreService {
/** GET /pets */
listPets(params?: { species?: string; limit?: number }): Promise
/** POST /pets */
createPet(body: CreatePetRequest): Promise
/** GET /pets/{id} */
getPet(id: string): Promise
/** DELETE /pets/{id} */
deletePet(id: string): Promise
}
```
The interface is regenerated every time the spec changes. If you add an endpoint in the spec and forget to implement it, TypeScript will fail the build.
**Method name derivation:** method names come from `operationId` in your spec (sanitized to camelCase). When `operationId` is absent, the generator falls back to a `verb + path` heuristic: `GET /pets/{id}` becomes `getPetsById`, `POST /users` becomes `createUsers`. Any leading `/api/v{n}/` prefix (for example `/api/v1/`) is stripped before this heuristic runs, so `/api/v1/pets/{id}` and `/pets/{id}` both produce `getPetsById`.
### `router.ts`
[Section titled “router.ts”](#routerts)
* Hono
`createRouter(service)` returns a `Hono` instance. Mount it anywhere in your app:
```typescript
// generated/router.ts (auto-generated, framework: "hono")
import { Hono } from 'hono'
import type { CreatePetRequest } from './models.js'
import type { PetstoreService } from './service.js'
export function createRouter(service: PetstoreService): Hono {
const app = new Hono()
app.get('/pets', async (c) => {
const params = {
species: c.req.query('species') ?? undefined,
limit: c.req.query('limit') !== undefined ? Number(c.req.query('limit')) : undefined,
}
return c.json(await service.listPets(params))
})
app.post('/pets', async (c) => {
const body = await c.req.json()
return c.json(await service.createPet(body), 201)
})
app.get('/pets/:id', async (c) => {
return c.json(await service.getPet(c.req.param('id')))
})
app.delete('/pets/:id', async (c) => {
await service.deletePet(c.req.param('id'))
return new Response(null, { status: 204 })
})
return app
}
```
* Express
`createRouter(service)` returns an Express `Router`. Apply `express.json()` before mounting so `req.body` is populated:
```typescript
// generated/router.ts (auto-generated, framework: "express")
import { Router } from 'express'
import type { Request, Response } from 'express'
import type { CreatePetRequest } from './models.js'
import type { PetstoreService } from './service.js'
export function createRouter(service: PetstoreService): Router {
const router = Router()
router.get('/pets', async (req: Request, res: Response) => {
const params = {
species: req.query['species'] as string | undefined,
limit: Number(req.query['limit'] as string),
}
res.json(await service.listPets(params))
})
router.post('/pets', async (req: Request, res: Response) => {
const body = req.body as CreatePetRequest
res.status(201).json(await service.createPet(body))
})
router.get('/pets/:id', async (req: Request, res: Response) => {
res.json(await service.getPet(req.params['id']!))
})
router.delete('/pets/:id', async (req: Request, res: Response) => {
await service.deletePet(req.params['id']!)
res.status(204).end()
})
return router
}
```
* Fastify
`createRouter(service)` returns a `FastifyPluginAsyncZod` factory. Mount it with `fastify.register` and apply a path prefix in the register options. The router sets up `fastify-type-provider-zod` and an `HttpError` `setErrorHandler` inside the plugin scope:
```typescript
// generated/router.ts (auto-generated, framework: "fastify")
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
import type { CreatePetRequest } from './models.js'
import type { PetstoreService } from './service.js'
import { HttpError } from './_shared/errors.js'
export { HttpError } from './_shared/errors.js'
export function createRouter(service: PetstoreService): FastifyPluginAsyncZod {
return async (app) => {
// Validation, serialization, and the HttpError handler are wired here,
// inside the plugin scope, before any routes are registered.
app.get('/pets', async (req) => {
return service.listPets(req.query)
})
app.post('/pets', async (req, reply) => {
reply.status(201)
return service.createPet(req.body)
})
app.get('/pets/:id', async (req) => {
return service.getPet(req.params.id)
})
app.delete('/pets/:id', async (req, reply) => {
await service.deletePet(req.params.id)
reply.status(204).send()
})
}
}
```
The router handles:
* Path params: `{id}` becomes `:id`, extracted via the framework’s native param accessor
* Query params: extracted and typed (`string`, `number`, `boolean`)
* Request bodies: parsed and cast to the correct model type. On Fastify, `req.body`, `req.query`, and `req.params` are inferred from the attached Zod schemas, so handlers are fully typed with no manual generics
* Response status: spec-driven. The generator reads your spec’s response codes first (checking for `201`, then `204`, then `200`). When no explicit code is declared, it falls back to `204` for DELETE and `200` for all other methods.
## Usage examples
[Section titled “Usage examples”](#usage-examples)
### Hono: mounting at a prefix
[Section titled “Hono: mounting at a prefix”](#hono-mounting-at-a-prefix)
`createRouter` returns a plain `Hono` instance. Mount it at any path prefix, add middleware, or nest it inside a larger app:
src/server/index.ts
```typescript
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { createRouter } from '../generated/router.js'
import { petService } from './petService.js'
const app = new Hono()
app.route('/api', createRouter(petService))
serve({ fetch: app.fetch, port: 3001 })
```
### Express and Fastify
[Section titled “Express and Fastify”](#express-and-fastify)
* Express
Set `"framework": "express"` and the generator produces a `createRouter` that returns an Express `Router`. Apply `express.json()` middleware before mounting so `req.body` is populated:
src/server/index.ts
```typescript
import express from 'express'
import { createRouter } from '../generated/router.js'
import { petService } from './petService.js'
const app = express()
app.use(express.json())
app.use('/api', createRouter(petService))
app.listen(3001)
```
* Fastify
Set `"framework": "fastify"` and `createRouter(service)` returns a `FastifyPluginAsyncZod`. Mount it with `fastify.register` and pass the prefix in the register options. Add `fastify` and `fastify-type-provider-zod` to your own `dependencies`:
src/server/index.ts
```typescript
import Fastify from 'fastify'
import { createRouter } from '../generated/router.js'
import { petService } from './petService.js'
const fastify = Fastify()
fastify.register(createRouter(petService), { prefix: '/api' })
fastify.listen({ port: 3001 })
```
The plugin installs `fastify-type-provider-zod` and an `HttpError` `setErrorHandler` inside its own scope, so request and response validation are native and stay encapsulated to the mounted routes.
### Framework-agnostic: `"framework": "none"`
[Section titled “Framework-agnostic: "framework": "none"”](#framework-agnostic-framework-none)
Use `"none"` when you want to own the routing layer entirely. The generator produces only `service.ts` and adds no runtime dependencies:
```json
{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "none"
}
```
Implement the interface and wire it to any router manually:
```typescript
import { createServer } from 'node:http'
import type { PetstoreService } from '../generated/service.js'
import { petService } from './petService.js'
// Wire PetstoreService however you like: vanilla http, Koa, Bun, Deno, or anything else.
```
### Zod validation (`input_schema`)
[Section titled “Zod validation (input\_schema)”](#zod-validation-input_schema)
Point `input_schema` at the same `schemas.ts` you use with `openapi-zod-ts`. For the Hono and Express routers the generator adds `safeParse` calls to every route that receives a request body. Invalid requests get a structured `422` response and never reach your service implementation. The Fastify router validates differently: it wires the same Zod schemas through `fastify-type-provider-zod` and rejects with Fastify’s native `400` (see the Fastify note below).
**Config:**
```json
{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "hono",
"input_schema": "./generated/schemas.ts"
}
```
**Generated router with validation (two-pass output):**
```typescript
app.post('/pets', async (c) => {
const body = await c.req.json()
// Validate request body: returns 422 with Zod issues on failure
const parseResult = CreatePetRequestSchema.safeParse(body)
if (!parseResult.success) {
return c.json({ error: 'Invalid request body', issues: parseResult.error.issues }, 422)
}
const validatedBody = parseResult.data
return c.json(await service.createPet(validatedBody), 201)
})
```
Invalid requests receive a structured `422` response:
```json
{
"error": "Invalid request body",
"issues": [{ "code": "too_small", "path": ["name"], "message": "Name is required" }]
}
```
Tip
The schema naming convention is `${TypeName}Schema`. For a request body type `CreatePetRequest`, the generator looks for `CreatePetRequestSchema` in the schema file. Unmatched schemas trigger a warning to stderr; builds still succeed.
Note
**Same schemas, both sides of the wire.** `openapi-zod-ts` validates outgoing requests in the browser. `@codewithagents/openapi-server` validates incoming requests on the server. One `schemas.ts`, one source of truth.
Note
**Fastify validation is native.** The Fastify router does not hand-roll the `safeParse` block shown above. It registers `fastify-type-provider-zod` once and attaches each operation’s Zod schemas to the route (`schema: { body, querystring, params, headers, response }`), so Fastify validates and types requests through its own pipeline. Validation failures return Fastify’s native `400` (`FST_ERR_VALIDATION`), not the `422 { error, issues }` envelope that Hono and Express emit. Cookie params (`in: cookie`) are the exception: Fastify has no native cookie schema, so those keep a `_ckv` safeParse block and still return `422 { error: 'Invalid request cookies', issues }`.
The generator runs in two passes: first it writes `router.ts` without any Zod imports, then it rewrites `router.ts` with validation for every route whose body type has a matching schema in `input_schema`. For Hono and Express this means `safeParse` calls; for Fastify it means schemas threaded through `fastify-type-provider-zod`. If the schema file does not exist yet at generation time, the second pass is skipped and validation is added on the next run.
## Middleware and authentication
[Section titled “Middleware and authentication”](#middleware-and-authentication)
The generated router is a plain framework object. Add any middleware before mounting it:
* Hono
```typescript
import { Hono } from 'hono'
import { bearerAuth } from 'hono/bearer-auth'
import { cors } from 'hono/cors'
import { createRouter } from '../generated/router.js'
import { petService } from './petService.js'
const app = new Hono()
// Auth and CORS apply to all /api routes
app.use('/api/*', cors())
app.use('/api/*', bearerAuth({ token: process.env.API_TOKEN! }))
app.route('/api', createRouter(petService))
```
* Express
```typescript
import express from 'express'
import { createRouter } from '../generated/router.js'
import { petService } from './petService.js'
const app = express()
app.use(express.json())
// Auth middleware runs before the generated router
app.use('/api', (req, res, next) => {
if (req.headers.authorization !== `Bearer ${process.env.API_TOKEN}`) {
return res.status(401).json({ error: 'Unauthorized' })
}
next()
})
app.use('/api', createRouter(petService))
```
* Fastify
```typescript
import Fastify from 'fastify'
import { createRouter } from '../generated/router.js'
import { petService } from './petService.js'
const app = Fastify()
// The Fastify router accepts plugin-scoped lifecycle hooks directly.
// onRequest fires before validation: ideal for auth.
app.register(
createRouter(petService, {
onRequest: async (req, reply) => {
if (req.headers.authorization !== `Bearer ${process.env.API_TOKEN}`) {
return reply.status(401).send({ error: 'Unauthorized' })
}
},
}),
{ prefix: '/api' }
)
```
The Fastify router takes `onRequest`, `preHandler`, `onSend`, and `onError` hooks in its `CreateRouterOptions`, each accepting a single handler or an array. They are plugin-scoped: they cover the generated routes (and any `registerCustomRoutes`) without leaking to the parent instance.
## Error handling
[Section titled “Error handling”](#error-handling)
The generated router maps thrown errors in two cases. Throw `HttpError` (re-exported from `router.ts`) from a service method to return a structured response at a chosen status; any other error propagates to your app-level handler. Hono and Express implement this with a per-route `try/catch`; Fastify registers a single `HttpError` `setErrorHandler` inside its plugin scope. Add a framework-level error handler to shape responses for the errors that propagate:
```typescript
import { HttpError } from '../generated/router.js'
// Inside a service method:
if (!pet) throw new HttpError(404, 'Pet not found')
```
* Hono
```typescript
import { Hono } from 'hono'
import { createRouter } from '../generated/router.js'
import { petService } from './petService.js'
const app = new Hono()
app.route('/api', createRouter(petService))
app.onError((err, c) => {
console.error(err)
return c.json({ error: err.message }, 500)
})
```
* Express
```typescript
import express, { NextFunction, Request, Response } from 'express'
import { createRouter } from '../generated/router.js'
import { petService } from './petService.js'
const app = express()
app.use(express.json())
app.use('/api', createRouter(petService))
// Error middleware must have four parameters
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err)
res.status(500).json({ error: err.message })
})
```
* Fastify
```typescript
import Fastify from 'fastify'
import { createRouter } from '../generated/router.js'
import { petService } from './petService.js'
const app = Fastify()
// The router maps HttpError itself. This app-level handler covers
// every other error the router re-throws.
app.setErrorHandler((err, req, reply) => {
console.error(err)
reply.status(500).send({ error: 'Internal server error' })
})
app.register(createRouter(petService), { prefix: '/api' })
```
## Request-scoped context (`context_type`)
[Section titled “Request-scoped context (context\_type)”](#request-scoped-context-context_type)
Set `context_type` to thread a typed caller context through every generated service method. Use it to pass an authentication principal, a tenant ID, or any per-request metadata without coupling service code to framework types. Each method gains a final `ctx` argument, and the interface becomes generic with a `Ctx = never` default so existing implementations keep compiling.
```json
{
"input_openapi": "./spec/api.json",
"output": "./generated",
"framework": "fastify",
"context_type": "RequestContext"
}
```
For Hono and Express the router passes the framework’s native request or context object as `ctx`. For Fastify, `context_type` is a runtime auth seam, not just a type: `createRouter` becomes generic over `Ctx` and **requires** an options object with a `createContext(req)` hook. The hook runs first inside every handler, so it is the place to authenticate the request and build the typed principal. Throw `HttpError(401)` to reject before any handler work runs; the return value is passed as `ctx` to every service method.
```typescript
import Fastify from 'fastify'
import { createRouter, HttpError } from '../generated/router.js'
import type { PetstoreService } from '../generated/service.js'
interface RequestContext {
userId: string
scopes: string[]
}
const petService: PetstoreService = {
async listPets(params, ctx) {
return db.listPets({ userId: ctx.userId, ...params })
},
// ... other operations receive ctx as their final argument
}
const app = Fastify()
app.register(
createRouter(petService, {
createContext: (req) => {
const userId = req.headers['x-user-id']
if (typeof userId !== 'string') throw new HttpError(401, 'Unauthorized')
return { userId, scopes: [] }
},
}),
{ prefix: '/v1' }
)
```
Operation-level `security` from the spec (falling back to the global `security`) is surfaced on each route at `req.routeOptions.config.security` as `Array<{ scheme, scopes }>`, and as a `@security` JSDoc tag on each service method. Security is metadata only: `createContext` (or a `preHandler` hook) is where you enforce it.
Tip
The canonical full-stack reference, [`petstore-fastify`](https://github.com/codewithagents/openapi-zod-ts/tree/main/packages/petstore-fastify), is a live demonstration of this seam: Fastify with `createContext` auth, a cross-field validation rule, and a React/react-query frontend that round-trips a cross-field error onto a form field.
## Custom routes and lifecycle hooks (Fastify)
[Section titled “Custom routes and lifecycle hooks (Fastify)”](#custom-routes-and-lifecycle-hooks-fastify)
The Fastify `createRouter` accepts a `CreateRouterOptions` object with several Fastify-only escape hatches:
* **`registerCustomRoutes(app)`**: register non-spec routes (health checks, metrics) that inherit the ZodTypeProvider context and the `HttpError` handler. Runs after the compilers and parsers are set up, before the spec routes.
* **`onRequest`, `preHandler`, `onSend`, `onError`**: plugin-scoped lifecycle hooks, each a single handler or an array. They cover generated and custom routes but do not propagate to the parent instance. Execution order per request: `onRequest` then `preHandler` then handler then `onSend`.
* **`registerParsers: false`**: skip auto-registering `@fastify/formbody` and `@fastify/multipart` so you can register them yourself with custom options.
`onError` hooks fire for observability only; the registered `HttpError` handler (or your `setErrorHandler`) remains the single response producer.
```typescript
import { createRouter, HttpError } from '../generated/router.js'
fastify.register(
createRouter(petService, {
registerCustomRoutes: async (app) => {
app.get('/health', async (_req, reply) => reply.send({ status: 'ok' }))
},
onRequest: authHook,
onError: [metricsHook, loggingHook],
}),
{ prefix: '/api' }
)
```
## Non-JSON request bodies (Fastify)
[Section titled “Non-JSON request bodies (Fastify)”](#non-json-request-bodies-fastify)
Fastify 5 natively parses only `application/json` and `text/plain`. For other content types declared in your spec, the Fastify router registers the required plugins automatically inside its plugin scope:
| Content type | What the router does | You install |
| ----------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------- |
| `application/x-www-form-urlencoded` | Auto-registers `@fastify/formbody` | `@fastify/formbody` |
| `multipart/form-data` | Auto-registers `@fastify/multipart` with `attachFieldsToBody: true` | `@fastify/multipart` |
| `application/octet-stream` | Emits an `addContentTypeParser` call; the body is forwarded to your service as a `Buffer` | nothing (core Fastify) |
Pass `registerParsers: false` to register the multipart plugin yourself when you need custom options such as upload size limits.
## Response validation opt-in (`emit_response_validation`, Fastify only)
[Section titled “Response validation opt-in (emit\_response\_validation, Fastify only)”](#response-validation-opt-in-emit_response_validation-fastify-only)
Set `emit_response_validation: true` to have the Fastify emitter synthesize inline Zod expressions for `schema.response` on routes that have no named schema from `input_schema`. The synthesizer is intentionally minimal: it handles flat objects with scalar properties, scalar top-level responses, and arrays of scalars, and falls back to `z.unknown()` for `$ref`, `allOf`, `oneOf`, `anyOf`, or nested objects. For complete, type-safe response validation, point `input_schema` at the `schemas.ts` from `openapi-zod-ts` instead.
## What is out of scope
[Section titled “What is out of scope”](#what-is-out-of-scope)
Coverage differs by framework. The Fastify router validates the most through `fastify-type-provider-zod`; the Hono and Express routers cover bodies via `safeParse` and leave the rest to you.
| Feature | Fastify | Hono / Express |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| Request body validation | Native via `fastify-type-provider-zod`. Rejects with `400 FST_ERR_VALIDATION`. | `safeParse` when `input_schema` is set. Rejects with `422 { error, issues }`. |
| Query and path validation | Native, against the Zod schemas attached to each route. | Not validated. Path params reach your service as raw strings. |
| Request header and cookie validation | Headers validated natively; cookie params validated via a `_ckv` safeParse block (`422`). Requires `@fastify/cookie`. | Not extracted or validated. |
| Response-body validation | Available. Point `input_schema` at your `schemas.ts`, or set `emit_response_validation` for simple inline shapes. | Not generated. Validate in your service if needed. |
| Security scheme enforcement | Surfaced as metadata: `req.routeOptions.config.security` and a `@security` JSDoc tag. Enforce it in `createContext` or a `preHandler`. | Not generated. Add auth middleware manually (see above). |
| `multipart/form-data` bodies | Auto-registers `@fastify/multipart` with `attachFieldsToBody: true` so `req.body` is populated. | Wire file upload routes manually. |
## Troubleshooting
[Section titled “Troubleshooting”](#troubleshooting)
**Requests are not validated; invalid bodies reach my service.** Check that `input_schema` is set in your config and the schema file exists. If the schema file is missing at generation time, the Zod pass is skipped and validation is not added. Also confirm that the schema name matches the convention: for a body type `CreatePetRequest`, the schema must be exported as `CreatePetRequestSchema`. A mismatch triggers a warning to stderr.
**`Cannot find module './models.js'` at runtime.** `service.ts` imports from `models.ts` which is generated by `openapi-zod-ts`. Run `openapi-zod-ts` before `openapi-server`. A combined script (`"generate": "openapi-zod-ts && openapi-server"`) prevents this.
**My service throws but the client sees `500` with no details.** The generated router maps `HttpError` to its status, but re-throws every other error to your app-level handler, which typically returns a bare `500`. Throw `HttpError(status, message)` for responses you control, and add a framework error handler as shown in the [Error handling](#error-handling) section for everything else.
## Works with
[Section titled “Works with”](#works-with)
`@codewithagents/openapi-server` is one of five published packages driven from the same OpenAPI 3.x spec: four generators (`openapi-zod-ts`, `openapi-server`, `openapi-react-query`, `openapi-msw`) plus `api-errors`, a runtime helper used in app code.
Types and fetch client
`openapi-zod-ts` generates the `models.ts` that `service.ts` imports from. Run both generators together against the same spec.
[Learn more](/openapi-zod-ts)
React Query hooks
Add `@codewithagents/openapi-react-query` to generate typed `useQuery` and `useMutation` hooks for the same spec your server implements.
[Learn more](/openapi-react-query)
MSW mock handlers
Add `@codewithagents/openapi-msw` to generate MSW v2 HTTP handlers with seeded Faker mock data, so the frontend can develop against the same spec before the server is live.
[Learn more](/openapi-msw)
Form error mapping
Add `@codewithagents/api-errors` to map API error responses from the generated client directly to form field errors.
[Learn more](/api-errors)
See the [`petstore-fastify` demo](https://github.com/codewithagents/openapi-zod-ts/tree/main/packages/petstore-fastify) for the canonical full-stack example: Fastify with `createContext` auth, a cross-field validation rule, and a React/react-query frontend, with these packages working together.
# Types and fetch client
> Generate TypeScript types, a native fetch client, and Zod schemas from your OpenAPI spec with openapi-zod-ts.
`openapi-zod-ts` reads your OpenAPI 3.x spec and writes a fully typed native `fetch` client, TypeScript interfaces, and optional Zod schemas into your project. Every generated file passes `prettier --check` and TypeScript `strict: true` out of the box, with zero runtime dependencies beyond `fetch`. The package is tested against 128 real-world specs (Stripe, GitHub, Spotify, OpenAI, and more) on every PR. The generated output for 13 of those (the showcase specs) is committed to the repo and drift-checked plus typechecked under `tsc --noEmit --strict` on every relevant PR, so regressions surface as a failing diff.
## Overview
[Section titled “Overview”](#overview)
Running the generator produces up to five files in your output directory:
| File | Contents |
| ------------------ | -------------------------------------------------------------------------------------- |
| `models.ts` | TypeScript interfaces and union types for every schema in `components.schemas` |
| `client.ts` | One typed `async function` per API operation, using native `fetch` |
| `client-config.ts` | `configureClient()` and `getConfig()`: set base URL, auth, and headers once at startup |
| `index.ts` | Barrel re-export of all three files above |
| `server.ts` | `createServerClient()` factory (generated only when `server_client: true`) |
When `input_schema` is set, the generator also bootstraps a `schemas.ts` file at the path you specify on the first run.
Key guarantees:
* **Zero runtime footprint.** The generated client uses only `fetch`. No axios, no wrapper libraries.
* **Prettier-clean.** Every file passes `prettier --check` with your project’s own Prettier config.
* **`strict: true`.** All output compiles cleanly with TypeScript strict mode.
* **SSR-safe.** Every generated function accepts an optional per-request config override. No global singleton mutation, safe for concurrent server requests.
* **OpenAPI 3.1.x** is the primary target (including 3.1.1). OpenAPI 3.0.x is supported in practice: 8 of the 13 showcase specs are 3.0.x and all compile under `tsc --strict`. Full support for `$ref`, `allOf`, `anyOf`, `oneOf`, and `nullable` (3.0.x `nullable: true` is normalized to the 3.1 null-union form, so nullable fields produce the correct `T | null` types).
## Install
[Section titled “Install”](#install)
* npm
```sh
npm i -D openapi-zod-ts
```
* pnpm
```sh
pnpm add -D openapi-zod-ts
```
* yarn
```sh
yarn add -D openapi-zod-ts
```
## Minimal example
[Section titled “Minimal example”](#minimal-example)
**1. Create `openapi-zod-ts.config.json` in your project root:**
```json
{
"input_openapi": "./openapi.json",
"output": "./src/api"
}
```
**2. Run the generator:**
```bash
npx openapi-zod-ts
```
**3. Import from the generated barrel:**
```typescript
import { configureClient } from './src/api'
import { listTasks, createTask, ApiError } from './src/api'
// Call once at app startup
configureClient({
baseUrl: 'https://api.example.com',
token: () => getAccessToken(),
})
// Every function is fully typed
const page = await listTasks({ status: 'pending', page: 1 })
try {
const task = await createTask({ title: 'Ship it' })
console.log(task.id)
} catch (err) {
if (err instanceof ApiError) {
console.error(err.status, err.body)
}
}
```
## Configuration
[Section titled “Configuration”](#configuration)
The config file may be a JSON file (`.json`) or a JS/ESM module (`.js`, `.mjs`, `.cjs`). By default the generator looks for `openapi-zod-ts.config.json` in the current working directory. JS config files can import the `defineConfig` helper from `openapi-zod-ts` for autocomplete and type safety:
openapi-zod-ts.config.mjs
```js
import { defineConfig } from 'openapi-zod-ts'
export default defineConfig({
input_openapi: './openapi.json',
output: './src/api',
})
```
### Full configuration reference
[Section titled “Full configuration reference”](#full-configuration-reference)
| Field | Type | Required | Default | Description |
| --------------- | --------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
| `input_openapi` | `string` | Yes | none | Path to your OpenAPI spec (JSON or YAML) |
| `output` | `string` | Yes | none | Directory to write the generated files |
| `input_schema` | `string` | No | none | Path to a user-owned Zod schema file. Bootstrapped on first run if the file does not exist; never overwritten after that |
| `baseUrl` | `string` | No | `""` | Default base URL embedded in the generated `client-config.ts` |
| `server_client` | `boolean` | No | `false` | When `true`, also generates `server.ts` with a `createServerClient()` factory |
**Full config example:**
```json
{
"input_openapi": "./openapi.json",
"output": "./src/api",
"input_schema": "./src/api/schemas.ts",
"baseUrl": "https://api.example.com",
"server_client": true
}
```
### Multi-spec projects array
[Section titled “Multi-spec projects array”](#multi-spec-projects-array)
When a single config file needs to drive generation for several OpenAPI specs, use a top-level `projects` array. Each entry is a full config object. The generator runs them sequentially.
```json
{
"projects": [
{ "input_openapi": "./services/users.json", "output": "./src/users" },
{ "input_openapi": "./services/orders.json", "output": "./src/orders" }
]
}
```
JS config files can use the `defineProjects` helper for the same result with type safety:
openapi-zod-ts.config.mjs
```js
import { defineProjects } from 'openapi-zod-ts'
export default defineProjects([
{ input_openapi: './services/users.json', output: './src/users' },
{ input_openapi: './services/orders.json', output: './src/orders' },
])
```
Caution
`projects` and top-level `input_openapi`/`output` are mutually exclusive. Mixing them in the same config file is a validation error. Use one form or the other.
## CLI reference
[Section titled “CLI reference”](#cli-reference)
```bash
npx openapi-zod-ts # uses openapi-zod-ts.config.json in CWD
npx openapi-zod-ts --config ./config/openapi-zod-ts.config.json # explicit path
```
| Flag | Argument | Description |
| ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--config` | `` | Path to a config file (`.json`, `.js`, `.mjs`, or `.cjs`). Relative paths inside the config file resolve from the config file’s directory, not from the shell’s working directory. |
| `--input` | `` | Path to the OpenAPI spec file. Overrides `input_openapi` from the config file for this run. |
| `--output` | `` | Output directory. Overrides `output` from the config file for this run. |
| `--watch` | *(none)* | Re-run generation whenever the spec file changes. Press Ctrl-C to exit. |
Note
The `--config` flag accepts `.json`, `.js`, `.mjs`, and `.cjs` files. Any other extension is rejected with an error. When `--config` is used, all relative paths inside the config file resolve from the config file’s directory, not from the shell’s working directory.
## Generated files in depth
[Section titled “Generated files in depth”](#generated-files-in-depth)
### `client-config.ts`
[Section titled “client-config.ts”](#client-configts)
The generated `ClientConfig` interface covers auth, credentials, and global error handling:
```typescript
// src/api/client-config.ts (auto-generated)
export interface ClientConfig {
baseUrl: string;
token?: string | (() => string | Promise);
credentials?: RequestCredentials;
headers?: Record;
onError?: (err: Error) => void;
}
export function configureClient(config: ClientConfig): void { ... }
export function getConfig(): Readonly { ... }
```
The default `credentials` is `'same-origin'`. When the spec declares a cookie-based `apiKey` security scheme, the generated default is `'include'` instead.
Call `configureClient` once at startup. Calling it a second time merges into the existing config rather than replacing it: fields not provided in the later call are inherited from the earlier call, not reset to defaults. The `token` field accepts a string or an async function, called per request, so JWT refresh flows work automatically.
### `models.ts`
[Section titled “models.ts”](#modelsts)
Each schema in `components.schemas` becomes a TypeScript interface or type alias. String enums also produce a `const` array for runtime iteration:
```typescript
// src/api/models.ts (auto-generated)
export type TaskStatus = 'pending' | 'in_progress' | 'done'
export const TaskStatusValues = ['pending', 'in_progress', 'done'] as const
export interface Task {
id: string
title: string
description?: string | null
status: TaskStatus
priority?: number
assigneeEmail?: string | null
createdAt: string /* date-time */
}
export interface CreateTaskRequest {
title: string
description?: string | null
priority?: number
assigneeEmail?: string | null
}
```
### `client.ts`
[Section titled “client.ts”](#clientts)
One typed `async function` per operation. Query parameters, path parameters, and request bodies are all typed. Each function accepts an optional `config` override as its final parameter:
```typescript
// src/api/client.ts (auto-generated)
export async function listTasks(
params?: {
status?: "pending" | "in_progress" | "done";
page?: number;
pageSize?: number;
},
config?: Partial,
): Promise { ... }
export async function createTask(
body: CreateTaskRequest,
config?: Partial,
): Promise { ... }
```
## Error handling
[Section titled “Error handling”](#error-handling)
Every generated function throws `ApiError` for non-2xx responses. The class is emitted directly into `client.ts`:
```typescript
// src/api/client.ts (auto-generated)
export class ApiError extends Error {
constructor(
public readonly status: number, // HTTP status code, e.g. 422
public readonly body: unknown // parsed JSON body, or null if parsing failed
) {
super(`API error ${status}`)
this.name = 'ApiError'
}
}
```
Use `instanceof` to narrow and read `err.status` and `err.body`:
```typescript
try {
const task = await createTask({ title: 'Ship it' })
} catch (err) {
if (err instanceof ApiError) {
console.error(err.status, err.body)
// err.body is unknown, narrow it before use:
if (typeof err.body === 'object' && err.body !== null && 'message' in err.body) {
console.error((err.body as { message: string }).message)
}
}
}
```
`ApiError` is also the direct input to [`@codewithagents/api-errors`](/api-errors), which maps validation errors to form field errors.
## Usage examples
[Section titled “Usage examples”](#usage-examples)
### Bearer token auth
[Section titled “Bearer token auth”](#bearer-token-auth)
```typescript
import { configureClient } from './src/api'
configureClient({
baseUrl: 'https://api.example.com',
token: () => getAccessToken(), // sync or async, called per request
credentials: 'omit',
})
```
### Cookie-based auth
[Section titled “Cookie-based auth”](#cookie-based-auth)
```typescript
configureClient({
baseUrl: 'https://api.example.com',
credentials: 'include', // sends HttpOnly cookies automatically
})
```
### Global error hook
[Section titled “Global error hook”](#global-error-hook)
```typescript
configureClient({
baseUrl: 'https://api.example.com',
onError: (err) => {
// called before every ApiError is thrown, use for logging or monitoring
Sentry.captureException(err)
},
})
```
### SSR with per-request config override
[Section titled “SSR with per-request config override”](#ssr-with-per-request-config-override)
Every generated function merges a `config` override into the global config for that single call. No singleton mutation, safe for concurrent server requests:
```typescript
// app/tasks/page.tsx (Next.js Server Component)
import { listTasks } from '@/api/client';
import { getServerSession } from 'next-auth';
export default async function TasksPage() {
const session = await getServerSession();
const page = await listTasks(
{ status: 'pending' },
{
baseUrl: process.env.API_URL, // absolute URL required on server
token: session.accessToken,
credentials: 'omit',
},
);
return ;
}
```
### Server client factory (`server_client: true`)
[Section titled “Server client factory (server\_client: true)”](#server-client-factory-server_client-true)
When `server_client: true` is set in config, the generator also writes `server.ts`. It exports `createServerClient()`, which pre-binds a per-request `ClientConfig` to every function so you only pass auth headers once per request:
```typescript
// src/api/server.ts (auto-generated)
export function createServerClient(config: Partial) {
return {
listTasks: (params?: Parameters[0]) => listTasks(params, config),
createTask: (body: Parameters[0]) => createTask(body, config),
getTask: (id: Parameters[0]) => getTask(id, config),
// ... all operations
}
}
```
Usage in a Next.js App Router page:
app/tasks/page.tsx
```typescript
import { createServerClient } from '@/api/server';
export default async function TasksPage() {
const session = await getServerSession();
const api = createServerClient({
baseUrl: process.env.API_URL,
token: session.accessToken,
});
const page = await api.listTasks({ status: 'pending' });
const task = await api.getTask('featured-id');
return ;
}
```
### Zod validation (`input_schema`)
[Section titled “Zod validation (input\_schema)”](#zod-validation-input_schema)
Point `input_schema` at a file path and the generator bootstraps a Zod schema file on first run. The file is never overwritten. Re-running the generator updates `models.ts` to use `z.infer<>` types and adds runtime validation to `client.ts`.
**Step 1: add `input_schema` to config**
```json
{
"input_openapi": "./openapi.json",
"output": "./src/api",
"input_schema": "./src/api/schemas.ts"
}
```
**Step 2: run the generator once** to bootstrap `schemas.ts`. Then edit it freely:
```typescript
// src/api/schemas.ts (bootstrapped, then yours)
import { z } from 'zod'
export const TaskSchema = z
.object({
id: z.string(),
title: z.string(),
status: z.enum(['pending', 'in_progress', 'done']),
})
.passthrough() // forward-compatible: unknown server fields are preserved
export const CreateTaskRequestSchema = z.object({
title: z.string().min(1, 'Title is required'),
})
```
**Step 3: re-run the generator.** `models.ts` switches to `z.infer<>` types, `client.ts` adds parse calls:
```typescript
// models.ts (regenerated with Zod integration)
import type { z } from 'zod'
import type { TaskSchema, CreateTaskRequestSchema } from './schemas.js'
export type Task = z.infer
export type CreateTaskRequest = z.infer
```
```typescript
// client.ts (regenerated with Zod integration)
export async function createTask(
body: CreateTaskRequest,
config?: Partial
): Promise {
CreateTaskRequestSchema.parse(body) // validates request body before sending
const res = await _request('POST', '/api/v1/tasks', { body }, config)
return TaskSchema.parse(await res.json()) // throws ZodError on bad response
}
```
Tip
The `.passthrough()` call on object schemas keeps unknown server fields in the parsed output, making schemas forward-compatible when the server adds new optional fields.
Note
If a schema exists in `schemas.ts` but has no matching component in the spec (or vice versa), the generator prints a drift warning to stderr. Your build still succeeds; the warning is advisory.
## Edge cases and gotchas
[Section titled “Edge cases and gotchas”](#edge-cases-and-gotchas)
### `input_schema` is bootstrapped once and never overwritten
[Section titled “input\_schema is bootstrapped once and never overwritten”](#input_schema-is-bootstrapped-once-and-never-overwritten)
The first time you run the generator with `input_schema` set, the file is created at the path you specified. Every subsequent run skips that write entirely. Delete the file and re-run to get a fresh bootstrap from the current spec.
### `--reset-schema` does not exist
[Section titled “--reset-schema does not exist”](#--reset-schema-does-not-exist)
The drift warning printed to stderr mentions “Run with —reset-schema to re-bootstrap.” That flag does not exist in the CLI. The only way to reset the schema file is to delete it manually and re-run the generator.
### Zod v4 is required
[Section titled “Zod v4 is required”](#zod-v4-is-required)
The bootstrapped `schemas.ts` uses `z.record(z.string(), ...)` (two-argument form) and `z.lazy()` for circular references. Both require Zod v4. Zod v3 does not support the two-argument `z.record` signature.
```bash
pnpm add zod@^4
```
### Request body validation uses `.parse()`, not `.strip().parse()`
[Section titled “Request body validation uses .parse(), not .strip().parse()”](#request-body-validation-uses-parse-not-stripparse)
The generated client calls `Schema.parse(body)` before sending, not `.strip().parse()`. This is intentional: not all generated schemas are `ZodObject` (for example `allOf` maps to `ZodIntersection` and `oneOf` maps to `ZodUnion`, neither of which has `.strip()`). Use a wrapping schema to strip UI-only fields:
schemas.ts
```typescript
export const CreateTaskRequestSchema = z.object({
title: z.string().min(1),
})
// For multi-step forms: extend with UI-only fields
export const CreateTaskFormSchema = CreateTaskRequestSchema.extend({
currentStep: z.number(),
termsAccepted: z.boolean(),
})
// Use CreateTaskFormSchema for React Hook Form.
// Strip UI fields before calling the API client:
const apiBody = CreateTaskRequestSchema.parse(formValues)
await createTask(apiBody)
```
### Path safety restrictions
[Section titled “Path safety restrictions”](#path-safety-restrictions)
The generator rejects `output` and `input_openapi` paths that resolve to system directories (`/etc`, `/usr`, `/bin`, `/sys`, `/proc`, `/dev`, `C:\Windows`, and similar). This is a security check that prevents accidental misconfiguration.
## Version and runtime requirements
[Section titled “Version and runtime requirements”](#version-and-runtime-requirements)
| Requirement | Minimum |
| -------------- | ---------------------------------------------------------------------------------------------- |
| Node.js | 18 (native `fetch` is required at runtime; the generator itself uses `fs/promises` and `path`) |
| TypeScript | 6.x (the version the monorepo runs); output targets `strict: true` |
| Module format | ESM only. The generated files use `.js` extensions in all imports (NodeNext resolution) |
| Prettier | Any version in your project. The generator reads your config to format output |
| Zod (optional) | v4 required when `input_schema` is used |
The package itself is published as ESM. It must be used from an ESM context (`"type": "module"` in `package.json` or a `.mjs` file).
## Troubleshooting
[Section titled “Troubleshooting”](#troubleshooting)
### ”Config file not found”
[Section titled “”Config file not found””](#config-file-not-found)
The generator looks for `openapi-zod-ts.config.json` in the current working directory. If you run the CLI from a subdirectory or use a different file name, pass `--config ` explicitly. The path argument must end in `.json`, `.js`, `.mjs`, or `.cjs`.
### Output files are not regenerating
[Section titled “Output files are not regenerating”](#output-files-are-not-regenerating)
The generator always overwrites `models.ts`, `client.ts`, `client-config.ts`, `index.ts`, and (if enabled) `server.ts` on every run. However, `schemas.ts` (the `input_schema` file) is never overwritten after the first bootstrap. If you want a fresh Zod schema file, delete it and re-run.
### Zod validation is not running at runtime
[Section titled “Zod validation is not running at runtime”](#zod-validation-is-not-running-at-runtime)
Ensure your config includes `input_schema` pointing at an existing `schemas.ts` file. On first run with `input_schema` set but no file present, the generator bootstraps the schema file only; run the generator a second time to get the Zod-enhanced `models.ts` and `client.ts`.
## Works with
[Section titled “Works with”](#works-with)
`openapi-zod-ts` is one of four generators that emit code from the same spec (`openapi-zod-ts`, `openapi-server`, `openapi-react-query`, `openapi-msw`), plus `api-errors`, a runtime helper used in app code.
React Query hooks
Add `@codewithagents/openapi-react-query` to generate typed `useQuery` and `useMutation` hooks on top of the client this package produces.
[Learn more](/openapi-react-query)
Server interface
Add `@codewithagents/openapi-server` to generate a typed service interface and an optional router targeting `hono | express | fastify | none` (default `none`) from the same spec.
[Learn more](/openapi-server)
MSW mock handlers
Add `@codewithagents/openapi-msw` to generate MSW v2 HTTP handlers with seeded Faker mock data from the same spec.
[Learn more](/openapi-msw)
Form error mapping
Add `@codewithagents/api-errors` to map `ApiError` responses from the generated client directly to form field errors.
[Learn more](/api-errors)
# Roadmap
> What is shipped, what is in progress, and what is planned for the CodeWithAgents OpenAPI toolchain.
The mission of this toolchain is to be a zero-footprint bridge between OpenAPI and Zod for both sides of the wire. Every package is a `devDependency` or a peer-dep-only tool. Nothing published here adds bytes to a production bundle that are not already there.
This page mirrors the [`ROADMAP.md`](https://github.com/codewithagents/openapi-zod-ts/blob/main/ROADMAP.md) in the repo. The five published packages are four generators (`openapi-zod-ts`, `@codewithagents/openapi-server`, `@codewithagents/openapi-react-query`, `@codewithagents/openapi-msw`) plus one runtime helper (`@codewithagents/api-errors`).
| Package | Version | One-liner |
| ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ |
| `openapi-zod-ts` | v2.1.0 | Core generator: TypeScript models, native fetch client, and Zod schemas from an OpenAPI 3.1 spec. CLI: `openapi-zod-ts`. |
| `@codewithagents/openapi-server` | v2.0.0 | Framework-agnostic service interface plus an optional router. CLI: `openapi-server`. |
| `@codewithagents/openapi-react-query` | v3.12.1 | Typed React Query v5 hooks over the generated client. |
| `@codewithagents/openapi-msw` | v0.2.0 | MSW v2 handlers with seeded Faker mock data. CLI: `openapi-msw`. |
| `@codewithagents/api-errors` | v1.2.0 | Maps backend API error responses to form-field errors. Runtime helper, not a codegen step. |
## Now: shipped
[Section titled “Now: shipped”](#now-shipped)
### `@codewithagents/api-errors` (v1.2.0)
[Section titled “@codewithagents/api-errors (v1.2.0)”](#codewithagentsapi-errors-v120)
Maps backend API error responses to form field errors. Framework-agnostic core with adapters for React Hook Form, Formik, TanStack Form, and vanilla JavaScript.
Supported parsers: RFC 7807 / RFC 9457 Problem Details, Spring Boot validation errors, violations / invalid-params shapes, JSON:API error objects, Laravel and DRF error formats, flat field/message objects, GraphQL error extensions, Zod flatten output, and Axios response wrappers.
### `openapi-zod-ts` (v2.1.0)
[Section titled “openapi-zod-ts (v2.1.0)”](#openapi-zod-ts-v210)
A CLI `devDependency` that reads an OpenAPI 3.1 spec and an optional user-owned Zod schema, then generates self-contained TypeScript. No runtime package required. The generated client uses only native `fetch`. Ships the `openapi-zod-ts` CLI.
Core features shipped:
* `models.ts`: TypeScript interfaces for every schema in `components.schemas`
* `client.ts`: one typed `async function` per API operation
* `client-config.ts`: `configureClient()` and `getConfig()` for base URL and auth
* `index.ts`: barrel re-export of all three files
* Optional Zod integration via `input_schema`: bootstraps `schemas.ts` on first run, then validates requests and responses on every call
* Optional `server.ts` via `server_client: true`: `createServerClient()` factory for SSR
* Multi-spec `projects` array in config: drive N separate generations from one config file
### `@codewithagents/openapi-react-query` (v3.12.1)
[Section titled “@codewithagents/openapi-react-query (v3.12.1)”](#codewithagentsopenapi-react-query-v3121)
A separate package layered on top of `openapi-zod-ts`. Reads the same `openapi.json` and the output of the generator, then produces React Query v5 hooks.
* `queryOptions` factories for GET endpoints
* `useMutation` hooks for write operations
* `useInfiniteQuery` hooks for paginated endpoints
* Imports from `openapi-zod-ts`’s `client.ts` output
* Peer dependency: `@tanstack/react-query`
### `@codewithagents/openapi-server` (v2.0.0)
[Section titled “@codewithagents/openapi-server (v2.0.0)”](#codewithagentsopenapi-server-v200)
Server-side counterpart to `openapi-zod-ts`. Reads the same `openapi.json` and generates a framework-agnostic service interface plus an optional router selected by `framework`: `"hono" | "express" | "fastify" | "none"` (default `"none"`, which emits only the service interface and lets you wire it to any framework yourself). Fastify is the framework the project ships its canonical full-stack app on. Ships the `openapi-server` CLI.
Peer dependencies: `hono`, `express`, or `fastify` depending on the chosen framework.
### `@codewithagents/openapi-msw` (v0.2.0)
[Section titled “@codewithagents/openapi-msw (v0.2.0)”](#codewithagentsopenapi-msw-v020)
Generates MSW v2 HTTP handlers with seeded Faker mock data from an OpenAPI 3.1 spec. Depends on `openapi-zod-ts`. Produces a single `handlers.ts` file ready to drop into a Storybook or Vitest browser setup. Ships the `openapi-msw` CLI.
* One `http.(path, resolver)` handler per operation
* Faker-seeded responses derived from response schemas
* Peer dependencies: `msw` (`^2`) and `@faker-js/faker` (`^9`)
## The full-stack story
[Section titled “The full-stack story”](#the-full-stack-story)
One spec drives four generators. The shared `schemas.ts` is the single source of validation truth across the wire.
```plaintext
openapi.json + schemas.ts
├── openapi-zod-ts → client validates responses with the user's Zod schemas
├── @codewithagents/openapi-server → server validates requests with the same Zod schemas
├── @codewithagents/openapi-react-query → React Query v5 hooks over the generated client
└── @codewithagents/openapi-msw → MSW v2 handlers with seeded Faker mock data
```
`@codewithagents/api-errors` then maps server validation failures back onto form fields in app code. The canonical end-to-end reference wiring all of this together is [`packages/petstore-fastify`](https://github.com/codewithagents/openapi-zod-ts/tree/main/packages/petstore-fastify): Fastify plus `createContext` auth, a cross-field validation rule, and a React / react-query frontend with browser e2e that round-trips a cross-field error onto a form field. `@codewithagents/petstore-hono` is a retained legacy Hono variant.
Note
This roadmap reflects the current state of the project. Priorities shift as feedback comes in. Open an issue on [GitHub](https://github.com/codewithagents/openapi-zod-ts) if something here matters to your project.
# Full-stack tutorial: OpenAPI spec to typed full-stack app in one command
> End-to-end walkthrough of the petstore-fastify demo. One OpenAPI spec generates a typed fetch client, Zod v4 schemas, React Query v5 hooks, and a Fastify router. Run pnpm generate and get a full-stack typed app with cross-field validation.
One `pnpm generate` command reads an OpenAPI spec and writes a typed fetch client, Zod v4 schemas, React Query v5 hooks, AND a Fastify router. Every file in `generated/` and `generated-auth/` comes from codegen. The only TypeScript you write by hand is the part the spec cannot express: business logic, auth, and the one cross-field validation rule.
This tutorial walks through the [`petstore-fastify` demo](https://github.com/codewithagents/openapi-zod-ts/tree/main/packages/petstore-fastify), the one canonical full-stack reference app for the toolchain. One OpenAPI spec drives TypeScript types, a native fetch client, a Fastify service interface and router with Zod validation, and React Query v5 hooks. A React frontend logs in for a bearer token, then submits a secured form whose cross-field validation rule round-trips its error onto the exact field that failed. Nothing in `generated/` or `generated-auth/` is written by hand.
The codegen surface here is four generators (`openapi-zod-ts`, `openapi-server`, `openapi-react-query`, `openapi-msw`) plus `@codewithagents/api-errors`, a runtime helper for mapping API errors to form fields. This app exercises the first three generators across two specs and shows where the other two fit.
Note
A retained legacy Hono variant, `petstore-hono`, keeps Hono router coverage alive but is no longer the canonical reference. Fastify is the framework this project ships its reference app on.
## What you will build
[Section titled “What you will build”](#what-you-will-build)
The Auth Lab slice of the app: a login screen that exchanges credentials for a bearer token, then a secured contact form. The contact form has a conditional cross-field rule. When the contact method is `email`, the email field is required; when it is `phone`, the phone field is required. The full round-trip looks like this:
```plaintext
React form -> ContactRequestSchema.parse(body) (generated client, before the request)
-> superRefine fails: issue { path: ["email"], message: "Email is required when method is email" }
-> ZodError thrown client-side, caught in useContact onError
-> React renders the message on the email field
```
The same shared schema also guards the server: an inject test proves a missing-field payload is rejected before any service code runs. One Zod `superRefine`, written once in `src/auth-schemas.ts`, protects both ends.
| Layer | File | Generated? |
| ------------------------------- | -------------------------------- | ----------------------------- |
| TypeScript types | `generated-auth/schema-types.ts` | Yes, by `openapi-zod-ts` |
| Fetch client | `generated-auth/client.ts` | Yes, by `openapi-zod-ts` |
| Zod schemas + cross-field rule | `src/auth-schemas.ts` | Bootstrapped once, then yours |
| Server interface | `generated-auth/service.ts` | Yes, by `openapi-server` |
| Fastify router + Zod validation | `generated-auth/router.ts` | Yes, by `openapi-server` |
| React Query hooks | `generated-auth/hooks.ts` | Yes, by `openapi-react-query` |
| Auth wiring + business logic | `src/server/authApp.ts` | You write this |
| React UI | `src/client/App.tsx` | You write this |
**The key insight:** everything in `generated/` and `generated-auth/` is disposable. Change a spec, run `pnpm generate`, and the types, client, hooks, and router regenerate. Your business logic in `src/` is untouched because it implements a stable TypeScript interface. `src/auth-schemas.ts` is the one exception inside the generated flow: the generator bootstraps it once, then never overwrites it, so the cross-field rule you add survives every regeneration.
## Quick start
[Section titled “Quick start”](#quick-start)
Prerequisites: Node.js 22+, pnpm 10+.
```bash
git clone https://github.com/codewithagents/openapi-zod-ts.git
cd openapi-zod-ts
pnpm install
pnpm build
cd packages/petstore-fastify
pnpm dev
```
`pnpm dev` regenerates from the specs, then starts two processes via `concurrently`:
* **Vite** on `http://localhost:5173`: the React frontend with hot reload
* **Fastify** on `http://localhost:3004` (`fullstackServer.ts`): serves the API plus the built frontend; Vite proxies `/api` requests to it
Open `http://localhost:5173`, log in with any username and password, and you will land on the secured contact form. There is also a backend-only server: `pnpm start` boots the pet API alone on port 3003 (`src/server/index.ts`).
***
## 1. The OpenAPI spec
[Section titled “1. The OpenAPI spec”](#1-the-openapi-spec)
The Auth Lab slice starts from `spec/auth-lab.json`, an OpenAPI 3.1 document with three operations: a public `login`, a secured `getMe`, and a secured `contact`. Bearer auth is declared at the top level, and `login` opts out with `security: []`:
```json
{
"openapi": "3.1.0",
"info": { "title": "Auth Lab", "version": "1.0.0" },
"components": {
"securitySchemes": {
"bearerAuth": { "type": "http", "scheme": "bearer" }
},
"schemas": {
"ContactRequest": {
"type": "object",
"required": ["method", "message"],
"properties": {
"method": { "type": "string", "enum": ["email", "phone"] },
"email": { "type": "string" },
"phone": { "type": "string" },
"message": { "type": "string" }
}
}
}
},
"security": [{ "bearerAuth": [] }],
"paths": {
"/login": { "post": { "operationId": "login", "security": [] } },
"/me": {
"get": {
"operationId": "getMe",
"security": [{ "bearerAuth": ["profile:read"] }]
}
},
"/contact": {
"post": {
"operationId": "contact",
"security": [{ "bearerAuth": ["profile:read"] }]
}
}
}
}
```
The spec describes shape and security, but not the cross-field rule. The spec cannot express “email is required only when method is email”; that is conditional logic, and it belongs in a Zod schema you own. The spec is the source of truth for every generated file; the cross-field rule is the source of truth for the one thing the spec cannot say.
Note
This app actually generates from two specs. The Pet API spec lives in the shared package at `../petstore-shared/spec/api.json` and produces plain CRUD into `generated/`. The Auth Lab spec, `spec/auth-lab.json`, produces the secured surface into `generated-auth/`. This tutorial follows the Auth Lab target because the cross-field round-trip is the headline feature.
## 2. Generate types, client, and Zod schemas
[Section titled “2. Generate types, client, and Zod schemas”](#2-generate-types-client-and-zod-schemas)
The first generator to run is `openapi-zod-ts`. It reads the spec and writes `schema-types.ts`, `client.ts`, `client-config.ts`, and `index.ts`. When `input_schema` points at a file, it bootstraps that file with a plain `z.object()` per schema on the first run, then never touches it again.
**`openapi-zod-ts.auth.config.json`:**
```json
{
"input_openapi": "spec/auth-lab.json",
"output": "generated-auth/",
"input_schema": "src/auth-schemas.ts"
}
```
The `input_schema` field is the key. Because `src/auth-schemas.ts` already exists, the generator leaves it alone and derives the response types in `schema-types.ts` from it. This is where the cross-field rule lives:
```typescript
// src/auth-schemas.ts (bootstrapped by openapi-zod-ts, then yours)
import { z } from 'zod'
// ContactRequest carries a conditional cross-field rule: the contact field named by
// `method` is required.
export const ContactRequestSchema = z
.object({
method: z.enum(['email', 'phone']),
email: z.string().optional(),
phone: z.string().optional(),
message: z.string().min(1, 'Message is required'),
})
.superRefine((data, ctx) => {
if (data.method === 'email' && !data.email?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['email'],
message: 'Email is required when method is email',
})
}
if (data.method === 'phone' && !data.phone?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['phone'],
message: 'Phone is required when method is phone',
})
}
})
```
The `path: ['email']` and `path: ['phone']` on each issue are what makes the round-trip land on the right form field later. The generated `client.ts` calls `ContactRequestSchema.parse(body)` before sending `POST /contact`, so this rule runs client-side, before the network, on every submit:
```typescript
// generated-auth/client.ts (auto-generated, excerpt)
export async function contact(
body: ContactRequest,
config?: RequestConfig
): Promise {
ContactRequestSchema.parse(body)
// ...fetch POST /contact...
return ContactResponseSchema.parse(await res.json())
}
```
Note
`openapi-zod-ts` is a prerequisite for both `openapi-server` and `openapi-react-query`. Run it first so `schema-types.ts` exists before the other generators import from it.
## 3. Generate the server interface and Fastify router
[Section titled “3. Generate the server interface and Fastify router”](#3-generate-the-server-interface-and-fastify-router)
`openapi-server` reads the same spec and writes two files: a plain TypeScript interface (`service.ts`) and a ready-to-mount Fastify plugin (`router.ts`).
**`openapi-server.auth.config.json`:**
```json
{
"input_openapi": "spec/auth-lab.json",
"output": "generated-auth/",
"framework": "fastify",
"context_type": "AuthContext",
"input_schema": "src/auth-schemas.ts"
}
```
`framework` accepts `hono | express | fastify | none`, defaulting to `none` (a framework-agnostic interface with no router). Here it is `fastify`. `context_type: "AuthContext"` makes the generated interface generic over a context type you define, threaded into every secured method. `input_schema` points at the same `auth-schemas.ts`, so the router validates against the shared Zod schemas.
**Generated `generated-auth/service.ts`:**
```typescript
// generated-auth/service.ts (auto-generated)
import type {
ContactRequest,
ContactResponse,
LoginRequest,
LoginResponse,
User,
} from './schema-types.js'
export interface AuthLabService {
/**
* GET /me
* @security bearerAuth profile:read
*/
getMe(ctx: Ctx): Promise
/** POST /login */
login(body: LoginRequest, ctx: Ctx): Promise
/**
* POST /contact
* @security bearerAuth profile:read
*/
contact(body: ContactRequest, ctx: Ctx): Promise
}
```
Every secured method receives `ctx: Ctx` as its final argument. The interface is regenerated whenever the spec changes. Add an operation and forget to implement it, and TypeScript fails the build.
**Generated `generated-auth/router.ts` (Fastify plugin):**
The router is a `FastifyPluginAsyncZod`. It uses `fastify-type-provider-zod` so the request body and response are validated against the Zod schemas, registers an `HttpError` error handler, surfaces operation security metadata on the route config, and calls a `createContext` callback you supply before each handler:
```typescript
// generated-auth/router.ts (auto-generated, excerpt)
export function createRouter(
service: AuthLabService,
options: CreateRouterOptions
): FastifyPluginAsyncZod {
return async (app) => {
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
// Default HttpError handler maps HttpError(status, message) to a JSON error response.
app.post(
'/contact',
{
schema: { body: ContactRequestSchema, response: { 200: ContactResponseSchema } },
config: {
operationId: 'contact',
security: [{ scheme: 'bearerAuth', scopes: ['profile:read'] }],
},
},
async (req, reply) => {
const ctx = await options.createContext(req)
return reply.send(await service.contact(req.body, ctx))
}
)
// ...login and me routes...
}
}
```
Two things are wired automatically. The route `schema.body` is `ContactRequestSchema`, so Fastify validates the body (including the `superRefine` cross-field rule) before your handler runs and rejects a bad payload with a 400. And `config.security` carries the required scopes, which your `createContext` can inspect to decide whether a route is public or needs a token.
Tip
The `service.ts` interface has zero framework imports. Set `framework` to `none` for a framework-agnostic interface, or `hono` / `express` / `fastify` to also emit a router. See the [openapi-server guide](/openapi-server) for the full router surface.
## 4. Implement the service and the createContext auth seam
[Section titled “4. Implement the service and the createContext auth seam”](#4-implement-the-service-and-the-createcontext-auth-seam)
`src/server/authApp.ts` does three things: defines the `AuthContext` shape, implements `AuthLabService`, and registers the generated router with a `createContext` that enforces auth.
```typescript
// src/server/authApp.ts (you write this)
import fastifyStatic from '@fastify/static'
import Fastify from 'fastify'
import { createRouter, HttpError } from '../../generated-auth/router.js'
import type { AuthLabService } from '../../generated-auth/service.js'
// The principal threaded through all secured service methods.
export interface AuthContext {
userId: string
scopes: string[]
}
export const authLabService: AuthLabService = {
async getMe(ctx) {
return { id: ctx.userId, name: `user-${ctx.userId}` }
},
async login(body) {
// Issue a token equal to the username so it can be echoed back in tests.
return { token: body.username, user: { id: body.username, name: `user-${body.username}` } }
},
async contact(body, ctx) {
void ctx
return { accepted: true, method: body.method }
},
}
export function buildAuthApp(options: { serveStatic?: string } = {}) {
const app = Fastify()
app.register(
createRouter(authLabService, {
createContext: (req) => {
const security = req.routeOptions.config.security
// Routes with no security metadata (login overrides with security: []) are public.
if (security === undefined || security.length === 0) {
return { userId: 'anonymous', scopes: [] }
}
// All other routes require a Bearer token.
const auth = req.headers['authorization']
if (typeof auth !== 'string' || !auth.startsWith('Bearer ')) {
throw new HttpError(401, 'Unauthorized')
}
return { userId: auth.slice('Bearer '.length), scopes: ['profile:read'] }
},
}),
{ prefix: '/api' }
)
if (options.serveStatic !== undefined) {
app.register(fastifyStatic, { root: options.serveStatic })
}
return app
}
```
The `createContext` callback is the auth seam. It reads the operation’s security metadata that the generated router placed on `req.routeOptions.config.security`, treats a missing or empty list as public, and otherwise requires a Bearer token. Throwing `HttpError(401)` here rejects the request before any service method runs. The returned `AuthContext` is threaded into `getMe` and `contact` as their `ctx` argument.
The router is mounted with `app.register(createRouter(...), { prefix: '/api' })`, so the generated routes live under `/api`. `fullstackServer.ts` calls `buildAuthApp({ serveStatic: dist })` to serve the built React frontend alongside the API on port 3004.
Caution
The reference `createContext` is deliberately minimal: it checks for a Bearer token but does not verify it. The point of the seam is to show where real auth plugs in, not to ship a token validator. Replace the body of `createContext` with your own verification.
## 5. Generate React Query hooks
[Section titled “5. Generate React Query hooks”](#5-generate-react-query-hooks)
`openapi-react-query` reads the auth-lab spec and writes `hooks.ts` plus a `test-utils.ts` helper. It derives all types from the generated client functions, so there are no duplicate type declarations.
**`openapi-react-query.auth.config.json`:**
```json
{
"input_openapi": "spec/auth-lab.json",
"output": "generated-auth/",
"stale_time": 0,
"gc_time": 300000
}
```
After running, `generated-auth/hooks.ts` contains:
* `useGetMe(options?)`: a `useQuery` hook for the authenticated user
* `useLogin(options?)`: a `useMutation` hook that returns a token
* `useContact(options?)`: a `useMutation` hook for the secured contact form
* `meKeys`: a structured key factory (`meKeys.all()`, `meKeys.list()`) for cache invalidation
Each hook is typed against the generated client, so `useContact` already knows its variables are a `ContactRequest` and its error is an `ApiError`.
## 6. Use the hooks in React: login, then the round-trip
[Section titled “6. Use the hooks in React: login, then the round-trip”](#6-use-the-hooks-in-react-login-then-the-round-trip)
`src/client/App.tsx` logs in with `useLogin`, stores the token in a small module (`token.ts`) that the client reads on every request, then renders the secured contact form with `useContact`. The frontend reads `issue.path[0]` from a thrown `ZodError` and places the message on the matching field:
```tsx
// src/client/App.tsx (you write this, excerpt)
import { useState, type FormEvent } from 'react'
import { useContact, useLogin } from '../../generated-auth/hooks.js'
import { setToken } from './token.js'
type FieldErrors = { email?: string; phone?: string }
type ZodIssue = { path: (string | number)[]; message: string }
// Place a message on the email or phone slot; ignore any other field.
function assignField(errors: FieldErrors, field: unknown, message: string): FieldErrors {
if (field === 'email') return { ...errors, email: message }
if (field === 'phone') return { ...errors, phone: message }
return errors
}
function isZodLike(error: unknown): error is { issues: ZodIssue[] } {
return (
error != null &&
typeof error === 'object' &&
'issues' in error &&
Array.isArray((error as { issues: unknown }).issues)
)
}
// The generated client runs ContactRequestSchema.parse(body) before the request, so a
// cross-field failure throws a ZodError with path-tagged issues client-side.
function parseContactErrors(error: unknown): FieldErrors {
if (!isZodLike(error)) return {}
return error.issues.reduce((acc, issue) => assignField(acc, issue.path[0], issue.message), {})
}
function ContactView() {
const [method, setMethod] = useState<'email' | 'phone'>('email')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [message, setMessage] = useState('')
const [fieldErrors, setFieldErrors] = useState({})
const [success, setSuccess] = useState(false)
const contact = useContact({
onSuccess: () => {
setSuccess(true)
setFieldErrors({})
},
onError: (error) => setFieldErrors(parseContactErrors(error)),
})
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
setFieldErrors({})
setSuccess(false)
contact.mutate({
method,
email: email.trim() || undefined,
phone: phone.trim() || undefined,
message: message.trim(),
})
}
// ...form with email/phone/message fields, each rendering fieldErrors[field]...
}
```
The login token is held in `token.ts` and read by the client on every request, so secured calls carry `Authorization: Bearer ` without the components touching headers:
```tsx
// src/client/main.tsx (you write this, excerpt)
import { configureClient } from '../../generated-auth/client-config.js'
import { getToken } from './token.js'
// Every request carries the current bearer token (empty until login).
configureClient({ baseUrl: '/api', token: getToken })
```
When the user submits `method: 'email'` with an empty email, `useContact`’s `mutate` calls the generated `contact` client function, which runs `ContactRequestSchema.parse(body)`. The `superRefine` adds an issue at `path: ['email']`, `parse` throws a `ZodError`, `useContact`’s `onError` receives it, `parseContactErrors` reads `issue.path[0]`, and the message renders on the email field. The cross-field error has round-tripped onto the exact field that failed, before a single byte hit the network.
Tip
This demo maps the error inline because the shape is its own. When you want a library that normalizes many backend error formats and wires them to React Hook Form in one call, reach for `@codewithagents/api-errors` and its `mapApiErrors` adapter. See the [api-errors guide](/api-errors).
## 7. Regenerating from spec
[Section titled “7. Regenerating from spec”](#7-regenerating-from-spec)
`package.json` runs five generator CLI invocations across the two specs and three generators, in dependency order:
```json
{
"scripts": {
"generate": "openapi-zod-ts && openapi-zod-ts --config openapi-zod-ts.auth.config.json && openapi-server && openapi-server --config openapi-server.auth.config.json && openapi-react-query --config openapi-react-query.auth.config.json"
}
}
```
Read top to bottom: `openapi-zod-ts` on the pet spec, then on the auth-lab spec; `openapi-server` on each; then `openapi-react-query` on the auth-lab spec. Run it whenever you change a spec or `src/auth-schemas.ts`:
```bash
pnpm generate
```
`src/auth-schemas.ts` is never overwritten, so the cross-field rule survives. Everything else in `generated/` and `generated-auth/` is safe to delete and regenerate.
Note
A fourth generator, `@codewithagents/openapi-msw`, reads the same spec and emits MSW v2 HTTP handlers with seeded Faker mock data (CLI: `openapi-msw`). This reference app does not run it; MSW handler generation is exercised in the `integration` package. Add a step like `openapi-msw --config openapi-msw.config.json` to a generate script when you want browser or test mocks driven from the same contract. See the [openapi-msw guide](/openapi-msw).
## 8. Run the tests
[Section titled “8. Run the tests”](#8-run-the-tests)
Two layers cover the round-trip. Vitest inject tests hit the Fastify app in-process, including the server-side guard; a Playwright suite drives a real browser through login and the cross-field round-trip.
The inject test proves the shared schema rejects a missing field server-side, before any service code runs:
```typescript
// src/__tests__/auth-routes.test.ts (excerpt)
it('rejects a missing email when method is email (400)', async () => {
const app = buildAuthApp()
const res = await app.inject({
method: 'POST',
url: '/api/contact',
headers: { 'content-type': 'application/json', authorization: 'Bearer x' },
payload: JSON.stringify({ method: 'email', message: 'hi' }),
})
expect(res.statusCode).toBe(400)
await app.close()
})
```
The browser e2e logs in, submits `method: 'email'` with no email, asserts the error lands on the email field, then provides a valid email and asserts success:
```typescript
// e2e-auth/auth.spec.ts (excerpt)
test('login, cross-field validation error round-trip, then success', async ({ page }) => {
await page.goto('/')
await page.getByTestId('login-username').fill('alice')
await page.getByTestId('login-password').fill('pw')
await page.getByTestId('login-submit').click()
// method=email but no email: the cross-field rule fails and the error lands on the email field.
await page.getByTestId('contact-method').selectOption('email')
await page.getByTestId('contact-message').fill('hello there')
await page.getByTestId('contact-submit').click()
const emailError = page.getByTestId('contact-email-error')
await expect(emailError).toBeVisible()
await expect(emailError).toContainText('email')
// Provide a valid email and resubmit: accepted.
await page.getByTestId('contact-email').fill('alice@example.com')
await page.getByTestId('contact-submit').click()
await expect(page.getByTestId('contact-success')).toBeVisible()
})
```
Run them:
```bash
pnpm test # Vitest inject tests (in-process Fastify, including the 400 guard)
pnpm test:e2e:auth # build, then Playwright on port 3004: login, round-trip, success
```
`pnpm test:e2e:auth` runs `pnpm build` first so `./dist` exists, then boots `fullstackServer.ts` on port 3004 and drives Chromium against it. These Playwright suites run in CI as jobs within the standard pipeline, not as a separate top-level workflow. There is no dedicated “E2E” workflow file.
***
## File structure
[Section titled “File structure”](#file-structure)
```plaintext
spec/
auth-lab.json Auth Lab spec: login, secured /me and /contact
../petstore-shared/ Shared Pet API contract (spec + hand-written Zod), reused by every petstore app
spec/api.json
schemas.ts
src/
auth-schemas.ts User-owned Zod: the cross-field superRefine lives here, never overwritten
server/
authApp.ts buildAuthApp: createContext auth seam + AuthLabService impl
fullstackServer.ts Serves the built frontend plus the auth API on port 3004
index.ts Pet API server only, on port 3003
petService.ts Implements the Pet API service
client/
App.tsx React UI: login then the secured contact form
main.tsx configureClient + QueryClientProvider
token.ts Module-level bearer token holder
generated/ Pet API output (gitignored, regenerated)
generated-auth/ Auth Lab output (gitignored, regenerated)
schema-types.ts TypeScript types
client.ts Typed fetch functions, ApiError class
client-config.ts configureClient(): base URL and token setup
service.ts AuthLabService interface
router.ts createRouter(service, { createContext }): Fastify plugin
hooks.ts useGetMe, useLogin, useContact, meKeys
e2e-auth/
auth.spec.ts Playwright: login, cross-field round-trip, success
openapi-zod-ts.auth.config.json Generator config (auth client + reads auth-schemas)
openapi-server.auth.config.json Generator config (Fastify router, context_type: AuthContext)
openapi-react-query.auth.config.json Generator config (React Query hooks)
```
***
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
Types and fetch client
Full reference for `openapi-zod-ts`: all config options, Zod integration, SSR server client, and more.
[openapi-zod-ts guide](/openapi-zod-ts)
Server interface
Full reference for `@codewithagents/openapi-server`: the `hono | express | fastify | none` targets, createContext, and Zod validation.
[openapi-server guide](/openapi-server)
React Query hooks
Full reference for `@codewithagents/openapi-react-query`: suspense variants, auto-invalidate, per-resource cache tuning, and test utilities.
[openapi-react-query guide](/openapi-react-query)
MSW mock handlers
`@codewithagents/openapi-msw` generates MSW v2 handlers with seeded Faker data from the same spec.
[openapi-msw guide](/openapi-msw)
Form error mapping
`@codewithagents/api-errors` normalizes common server error formats and wires them to React Hook Form in one call.
[api-errors guide](/api-errors)