Types and fetch client
Full reference for openapi-zod-ts: all config options, Zod integration, SSR server client, and more.
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, 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.
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:
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 fieldThe 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.
Prerequisites: Node.js 22+, pnpm 10+.
git clone https://github.com/codewithagents/openapi-zod-ts.gitcd openapi-zod-tspnpm installpnpm buildcd packages/petstore-fastifypnpm devpnpm dev regenerates from the specs, then starts two processes via concurrently:
http://localhost:5173: the React frontend with hot reloadhttp://localhost:3004 (fullstackServer.ts): serves the API plus the built frontend; Vite proxies /api requests to itOpen 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).
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: []:
{ "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.
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:
{ "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:
// 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:
// generated-auth/client.ts (auto-generated, excerpt)export async function contact( body: ContactRequest, config?: RequestConfig): Promise<ContactResponse> { ContactRequestSchema.parse(body) // ...fetch POST /contact... return ContactResponseSchema.parse(await res.json())}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:
{ "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:
// generated-auth/service.ts (auto-generated)import type { ContactRequest, ContactResponse, LoginRequest, LoginResponse, User,} from './schema-types.js'
export interface AuthLabService<Ctx = never> { /** * GET /me * @security bearerAuth profile:read */ getMe(ctx: Ctx): Promise<User> /** POST /login */ login(body: LoginRequest, ctx: Ctx): Promise<LoginResponse> /** * POST /contact * @security bearerAuth profile:read */ contact(body: ContactRequest, ctx: Ctx): Promise<ContactResponse>}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:
// generated-auth/router.ts (auto-generated, excerpt)export function createRouter<Ctx = never>( service: AuthLabService<Ctx>, options: CreateRouterOptions<Ctx>): 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.
src/server/authApp.ts does three things: defines the AuthContext shape, implements AuthLabService<AuthContext>, and registers the generated router with a createContext that enforces auth.
// 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<AuthContext> = { 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<AuthContext>(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.
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:
{ "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 useruseLogin(options?): a useMutation hook that returns a tokenuseContact(options?): a useMutation hook for the secured contact formmeKeys: a structured key factory (meKeys.all(), meKeys.list()) for cache invalidationEach hook is typed against the generated client, so useContact already knows its variables are a ContactRequest and its error is an ApiError.
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:
// 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<FieldErrors>({}) 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 <token> without the components touching headers:
// 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.
package.json runs five generator CLI invocations across the two specs and three generators, in dependency order:
{ "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:
pnpm generatesrc/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.
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:
// 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:
// 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:
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, successpnpm 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.
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<Ctx> 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)Types and fetch client
Full reference for openapi-zod-ts: all config options, Zod integration, SSR server client, and more.
Server interface
Full reference for @codewithagents/openapi-server: the hono | express | fastify | none targets, createContext, and Zod validation.
React Query hooks
Full reference for @codewithagents/openapi-react-query: suspense variants, auto-invalidate, per-resource cache tuning, and test utilities.
MSW mock handlers
@codewithagents/openapi-msw generates MSW v2 handlers with seeded Faker data from the same spec.
Form error mapping
@codewithagents/api-errors normalizes common server error formats and wires them to React Hook Form in one call.