React Query hooks
Add @codewithagents/openapi-react-query to generate typed useQuery and useMutation hooks on top of the client this package produces.
@codewithagents/openapi-gen 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.
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:
fetch. No axios, no wrapper libraries.prettier --check with your project’s own Prettier config.strict: true. All output compiles cleanly with TypeScript strict mode.$ref, allOf, anyOf, oneOf, and nullable.npm i -D @codewithagents/openapi-genpnpm add -D @codewithagents/openapi-genyarn add -D @codewithagents/openapi-gen1. Create openapi-gen.config.json in your project root:
{ "input_openapi": "./openapi.json", "output": "./src/api"}2. Run the generator:
npx openapi-gen3. Import from the generated barrel:
import { configureClient } from './src/api'import { listTasks, createTask, ApiError } from './src/api'
// Call once at app startupconfigureClient({ baseUrl: 'https://api.example.com', token: () => getAccessToken(),})
// Every function is fully typedconst 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) }}The config file must be valid JSON and must have a .json extension. By default the generator looks for openapi-gen.config.json in the current working directory.
| 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:
{ "input_openapi": "./openapi.json", "output": "./src/api", "input_schema": "./src/api/schemas.ts", "baseUrl": "https://api.example.com", "server_client": true}npx openapi-gen # uses openapi-gen.config.json in CWDnpx openapi-gen --config ./config/openapi-gen.config.json # explicit path| Flag | Argument | Description |
|---|---|---|
--config | <path-to.json> | Path to a config file. Must end in .json. Relative paths inside the config file resolve from the config file’s directory, not from the shell’s working directory. |
client-config.tsThe generated ClientConfig interface covers auth, credentials, and global error handling:
// src/api/client-config.ts (auto-generated)export interface ClientConfig { baseUrl: string; token?: string | (() => string | Promise<string>); credentials?: RequestCredentials; headers?: Record<string, string>; onError?: (err: Error) => void;}
export function configureClient(config: ClientConfig): void { ... }export function getConfig(): Readonly<ClientConfig> { ... }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.tsEach schema in components.schemas becomes a TypeScript interface or type alias. String enums also produce a const array for runtime iteration:
// 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.tsOne 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:
// src/api/client.ts (auto-generated)export async function listTasks( params?: { status?: "pending" | "in_progress" | "done"; page?: number; pageSize?: number; }, config?: Partial<ClientConfig>,): Promise<TaskPage> { ... }
export async function createTask( body: CreateTaskRequest, config?: Partial<ClientConfig>,): Promise<Task> { ... }Every generated function throws ApiError for non-2xx responses. The class is emitted directly into client.ts:
// 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:
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, which maps validation errors to form field errors.
import { configureClient } from './src/api'
configureClient({ baseUrl: 'https://api.example.com', token: () => getAccessToken(), // sync or async, called per request credentials: 'omit',})configureClient({ baseUrl: 'https://api.example.com', credentials: 'include', // sends HttpOnly cookies automatically})configureClient({ baseUrl: 'https://api.example.com', onError: (err) => { // called before every ApiError is thrown — use for logging or monitoring Sentry.captureException(err) },})Every generated function merges a config override into the global config for that single call. No singleton mutation, safe for concurrent server requests:
// 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 <TaskList tasks={page.items} />;}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:
// src/api/server.ts (auto-generated)export function createServerClient(config: Partial<ClientConfig>) { return { listTasks: (params?: Parameters<typeof listTasks>[0]) => listTasks(params, config), createTask: (body: Parameters<typeof createTask>[0]) => createTask(body, config), getTask: (id: Parameters<typeof getTask>[0]) => getTask(id, config), // ... all operations }}Usage in a Next.js App Router page:
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 <TaskList tasks={page.items} featured={task} />;}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
{ "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:
// 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:
// models.ts (regenerated with Zod integration)import type { z } from 'zod'import type { TaskSchema, CreateTaskRequestSchema } from './schemas.js'
export type Task = z.infer<typeof TaskSchema>export type CreateTaskRequest = z.infer<typeof CreateTaskRequestSchema>// client.ts (regenerated with Zod integration)export async function createTask( body: CreateTaskRequest, config?: Partial<ClientConfig>): Promise<Task> { 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}input_schema is bootstrapped once and never overwrittenThe 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 existThe 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.
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.
pnpm add zod@^4.parse(), not .strip().parse()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:
export const CreateTaskRequestSchema = z.object({ title: z.string().min(1),})
// For multi-step forms: extend with UI-only fieldsexport 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)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.
| Requirement | Minimum |
|---|---|
| Node.js | 18 (native fetch is required at runtime; the generator itself uses fs/promises and path) |
| TypeScript | 5.x recommended; 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).
The generator looks for openapi-gen.config.json in the current working directory. If you run the CLI from a subdirectory or use a different file name, pass --config <path> explicitly. The path argument must end in .json.
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.
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.
React Query hooks
Add @codewithagents/openapi-react-query to generate typed useQuery and useMutation hooks on top of the client this package produces.
Server interface
Add @codewithagents/openapi-server to generate a typed service interface and optional Hono router from the same spec.
Form error mapping
Add @codewithagents/api-errors to map ApiError responses from the generated client directly to form field errors.