Types and fetch client
openapi-zod-ts generates the models.ts that service.ts imports from. Run both generators together against the same spec.
@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.
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:
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."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.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 --check with your project’s own Prettier config.strict: true. All output compiles cleanly with TypeScript strict mode.tsc --strict. Full support for $ref, allOf, anyOf, oneOf, and nullable.npm i -D @codewithagents/openapi-serverpnpm add -D @codewithagents/openapi-serveryarn add -D @codewithagents/openapi-server1. Create openapi-server.config.json in your project root:
{ "input_openapi": "./spec/api.json", "output": "./generated", "framework": "hono"}2. Run both generators:
npx openapi-zod-ts && npx openapi-serverOr add a combined script to package.json:
{ "scripts": { "generate": "openapi-zod-ts && openapi-server" }}3. Implement the generated service interface:
import { randomUUID } from 'node:crypto'import type { PetstoreService } from '../generated/service.js'import type { Pet } from '../generated/models.js'
const pets = new Map<string, Pet>()
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:
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 /apiconst apiRouter = createRouter(petService)app.route('/api', apiRouter)
serve({ fetch: app.fetch, port: 3001 })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 <path> to point at a different file.
npx openapi-server --config ./config/openapi-server.config.json| 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. |
emit_response_validation | boolean | No | false | Fastify only. Synthesize inline Zod response schemas for flat inline response shapes. See response validation. |
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:
{ "input_openapi": "./spec/api.json", "output": "./generated", "framework": "hono", "input_schema": "./generated/schemas.ts"}service.tsGiven a petstore spec (GET /pets, POST /pets, GET /pets/{id}, DELETE /pets/{id}), the generator produces a plain TypeScript interface with no framework dependencies:
// generated/service.ts (auto-generated)
import type { CreatePetRequest, Pet } from './models.js'
export interface PetstoreService { /** GET /pets */ listPets(params?: { species?: string; limit?: number }): Promise<Pet[]> /** POST /pets */ createPet(body: CreatePetRequest): Promise<Pet> /** GET /pets/{id} */ getPet(id: string): Promise<Pet> /** DELETE /pets/{id} */ deletePet(id: string): Promise<void>}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.tscreateRouter(service) returns a Hono instance. Mount it anywhere in your app:
// 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<CreatePetRequest>() 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}createRouter(service) returns an Express Router. Apply express.json() before mounting so req.body is populated:
// 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}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:
// 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:
{id} becomes :id, extracted via the framework’s native param accessorstring, number, boolean)req.body, req.query, and req.params are inferred from the attached Zod schemas, so handlers are fully typed with no manual generics201, then 204, then 200). When no explicit code is declared, it falls back to 204 for DELETE and 200 for all other methods.createRouter returns a plain Hono instance. Mount it at any path prefix, add middleware, or nest it inside a larger app:
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 })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:
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)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:
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": "none"Use "none" when you want to own the routing layer entirely. The generator produces only service.ts and adds no runtime dependencies:
{ "input_openapi": "./spec/api.json", "output": "./generated", "framework": "none"}Implement the interface and wire it to any router manually:
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.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:
{ "input_openapi": "./spec/api.json", "output": "./generated", "framework": "hono", "input_schema": "./generated/schemas.ts"}Generated router with validation (two-pass output):
app.post('/pets', async (c) => { const body = await c.req.json<CreatePetRequest>() // 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:
{ "error": "Invalid request body", "issues": [{ "code": "too_small", "path": ["name"], "message": "Name is required" }]}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.
The generated router is a plain framework object. Add any middleware before mounting it:
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 routesapp.use('/api/*', cors())app.use('/api/*', bearerAuth({ token: process.env.API_TOKEN! }))
app.route('/api', createRouter(petService))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 routerapp.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))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.
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:
import { HttpError } from '../generated/router.js'
// Inside a service method:if (!pet) throw new HttpError(404, 'Pet not found')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)})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 parametersapp.use((err: Error, req: Request, res: Response, next: NextFunction) => { console.error(err) res.status(500).json({ error: err.message })})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' })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.
{ "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.
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<RequestContext> = { 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.
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.
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' })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.
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.
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. |
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 section for everything else.
@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.
React Query hooks
Add @codewithagents/openapi-react-query to generate typed useQuery and useMutation hooks for the same spec your server implements.
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.
Form error mapping
Add @codewithagents/api-errors to map API error responses from the generated client directly to form field errors.
See the petstore-fastify demo 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.