Types and fetch client
@codewithagents/openapi-gen generates the typed fetch client and models that these hooks wrap. Run it first.
@codewithagents/openapi-react-query reads your OpenAPI 3.x spec and writes a fully typed hooks.ts file alongside the client that @codewithagents/openapi-gen produces. Every GET endpoint becomes a useQuery hook, every write operation becomes a useMutation hook. The package runs on top of React Query v5 and derives all types directly from the generated client: no duplicate type declarations, no maintenance.
Running the generator produces two files in your output directory:
| File | Contents |
|---|---|
hooks.ts | One typed useQuery hook per GET, one useMutation per POST/PUT/PATCH/DELETE, and a structured key factory per resource |
test-utils.ts | createTestQueryClient() and createWrapper() helpers: drop-in boilerplate eliminators for your test suites |
Key guarantees:
useQuery variant; every write maps to a useMutation. Types are derived directly from the generated client: Awaited<ReturnType<typeof fn>> for data, Parameters<typeof fn>[N] for variables.string | undefined | null and set enabled: false automatically when the value is nullish. No enabled: !!id at every call site.resourceKeys object: all(), list(params), detail(id). Use these directly with queryClient.invalidateQueries for consistent cache management.auto_invalidate: true and mutation hooks call queryClient.invalidateQueries on success with no boilerplate at the call site.suspense: true to generate a useSuspense* hook alongside every query hook. Data is never undefined inside a Suspense boundary.prettier --check with your project’s own config.npm i -D @codewithagents/openapi-react-querypnpm add -D @codewithagents/openapi-react-queryyarn add -D @codewithagents/openapi-react-queryYou also need @codewithagents/openapi-gen (the client generator this package builds on) and @tanstack/react-query as a runtime peer dependency:
npm i -D @codewithagents/openapi-genpnpm add -D @codewithagents/openapi-genyarn add -D @codewithagents/openapi-gennpm i @tanstack/react-querypnpm add @tanstack/react-queryyarn add @tanstack/react-query1. Create openapi-react-query.config.json in your project root:
{ "input_openapi": "./openapi.json", "output": "./src/api"}2. Run both generators (openapi-gen first):
npx openapi-gennpx openapi-react-queryOr wire them together in package.json:
{ "scripts": { "generate": "openapi-gen && openapi-react-query" }}3. Import from the generated hooks file:
import { useListTasks, useCreateTask } from './src/api/hooks'
function TaskList() { const { data, isLoading } = useListTasks({ status: 'open' }) const create = useCreateTask()
if (isLoading) return <Spinner /> return ( <> {data?.items.map((task) => ( <Task key={task.id} task={task} /> ))} <button onClick={() => create.mutate({ title: 'New task' })}>Add</button> </> )}The config file must be valid JSON. By default the generator looks for openapi-react-query.config.json in the current working directory. Use --config <path> to point at a different file:
npx openapi-react-query --config ./config/openapi-react-query.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 generated files (should match your openapi-gen output) |
stale_time | number | No | 0 | staleTime in ms applied to all useQuery hooks |
gc_time | number | No | 300000 | gcTime in ms applied to all useQuery hooks |
suspense | boolean | No | false | When true, generates a useSuspense* variant alongside every query hook |
auto_invalidate | boolean | No | false | When true, mutation hooks auto-invalidate related resource queries on success |
overrides | object | No | none | Per-resource cache timing overrides (see Per-resource cache timing) |
Full config example:
{ "input_openapi": "./openapi.json", "output": "./src/api", "stale_time": 30000, "gc_time": 300000, "suspense": true, "auto_invalidate": true, "overrides": { "platforms": { "stale_time": 86400000 }, "settings": { "stale_time": 5000, "gc_time": 60000 } }}hooks.tsThe main output file. For a spec with a /tasks resource the generated file contains three sections.
Key factory: one per resource, used for consistent cache invalidation:
// src/api/hooks.ts (auto-generated)export const taskKeys = { all: () => ['tasks'] as const, list: (params?: Parameters<typeof listTasks>[0]) => ['tasks', 'list', params] as const, detail: (id: string) => ['tasks', id] as const,}Query hooks: one per GET operation:
// List hook: params are optional, passed directly to the query key and fetch functionexport function useListTasks( params?: Parameters<typeof listTasks>[0], options?: Omit< UseQueryOptions<Awaited<ReturnType<typeof listTasks>>, ApiError>, 'queryKey' | 'queryFn' >) { return useQuery<Awaited<ReturnType<typeof listTasks>>, ApiError>({ queryKey: taskKeys.list(params), queryFn: () => listTasks(params), staleTime: 30000, gcTime: 300000, ...options, })}
// Detail hook: id widened to allow undefined/null, auto-disabled until id is setexport function useGetTask( id: string | undefined | null, options?: Omit< UseQueryOptions<Awaited<ReturnType<typeof getTask>>, ApiError>, 'queryKey' | 'queryFn' >) { return useQuery<Awaited<ReturnType<typeof getTask>>, ApiError>({ queryKey: taskKeys.detail(id!), queryFn: () => getTask(id!), staleTime: 30000, gcTime: 300000, enabled: id != null && (options?.enabled ?? true), ...options, })}Mutation hooks: one per POST/PUT/PATCH/DELETE:
export function useCreateTask( options?: Omit< UseMutationOptions< Awaited<ReturnType<typeof createTask>>, ApiError, Parameters<typeof createTask>[0] >, 'mutationFn' >) { return useMutation< Awaited<ReturnType<typeof createTask>>, ApiError, Parameters<typeof createTask>[0] >({ mutationFn: (vars) => createTask(vars), ...options, })}
// Update/patch: variables are destructured into { id, body } to match the client signatureexport function useUpdateTask( options?: Omit< UseMutationOptions< Awaited<ReturnType<typeof updateTask>>, ApiError, { id: string; body: Parameters<typeof updateTask>[1] } >, 'mutationFn' >) { return useMutation< Awaited<ReturnType<typeof updateTask>>, ApiError, { id: string; body: Parameters<typeof updateTask>[1] } >({ mutationFn: ({ id, body }) => updateTask(id, body), ...options, })}
// Delete with a single path param: variables type is 'string' directlyexport function useDeleteTask( options?: Omit< UseMutationOptions<Awaited<ReturnType<typeof deleteTask>>, ApiError, string>, 'mutationFn' >) { return useMutation<Awaited<ReturnType<typeof deleteTask>>, ApiError, string>({ mutationFn: (id) => deleteTask(id), ...options, })}test-utils.tsA testing helper file generated alongside hooks.ts. It exports createTestQueryClient() and createWrapper() with no new dependencies: everything it imports (@tanstack/react-query, react) is already a peer dependency you have installed.
See Testing your hooks for full copy-pasteable examples.
import { useListTasks } from './src/api/hooks'
function TaskBoard() { // params are optional; omitting them fetches all tasks const { data, isLoading, isError, error } = useListTasks({ status: 'pending', page: 1 })
if (isLoading) return <Spinner /> if (isError) return <p>Error: {error.message}</p>
return ( <ul> {data?.items.map((task) => ( <li key={task.id}>{task.title}</li> ))} </ul> )}The path-parameter hook (useGetTask) widens id to string | undefined | null and sets enabled: false automatically when the value is nullish. No enabled: !!id required at the call site:
import { useGetTask } from './src/api/hooks'
function TaskDetail({ taskId }: { taskId: string | null }) { // Hook does not fire while taskId is null; no boilerplate needed const { data, isLoading } = useGetTask(taskId)
if (isLoading) return <Spinner /> return <h2>{data?.title}</h2>}import { useCreateTask } from './src/api/hooks'
function CreateTaskForm() { const create = useCreateTask({ onSuccess: (task) => { console.log('Created task', task.id) }, onError: (err) => { console.error(err.status, err.body) }, })
return ( <form onSubmit={(e) => { e.preventDefault() create.mutate({ title: 'New task' }) }} > <button type="submit" disabled={create.isPending}> Add </button> </form> )}When suspense: true is set in config, a useSuspense* hook is generated alongside every query hook. The data type is never undefined inside a Suspense boundary:
import { Suspense } from 'react'import { useSuspenseGetTask } from './src/api/hooks'
function TaskDetail({ taskId }: { taskId: string }) { // data is guaranteed non-undefined: parent must wrap in <Suspense> const { data } = useSuspenseGetTask(taskId) return <h2>{data.title}</h2>}
function TaskPage({ taskId }: { taskId: string }) { return ( <Suspense fallback={<Spinner />}> <TaskDetail taskId={taskId} /> </Suspense> )}Set auto_invalidate: true in config and mutation hooks automatically call queryClient.invalidateQueries on success. No useQueryClient boilerplate at the call site:
// With auto_invalidate: true in configconst create = useCreateTask()create.mutate({ title: 'New task' })// taskKeys.all() is invalidated automatically on success
const update = useUpdateTask()update.mutate({ id: 'task-1', body: { title: 'Updated' } })// taskKeys.all() AND taskKeys.detail('task-1') are both invalidated on successInvalidation scope:
| HTTP method | Queries invalidated |
|---|---|
POST | resourceKeys.all() |
PUT / PATCH | resourceKeys.all() + resourceKeys.detail(id) |
DELETE | resourceKeys.all() |
Your own onSuccess callback (if provided in options) is called after auto-invalidation runs. The generated onSuccess composes your callback rather than replacing it. The emitted object spreads ...options first, then declares the onSuccess key immediately after, so the generated onSuccess intentionally overwrites any caller-provided onSuccess while still invoking it internally via options?.onSuccess?.(...args):
// The generated mutation hook (simplified, auto_invalidate: true)// ...options is spread first; onSuccess follows it, overriding any onSuccess in optionsonSuccess: (...args) => { queryClient.invalidateQueries({ queryKey: taskKeys.all() }) options?.onSuccess?.(...args) // your callback runs after invalidation}Use overrides in config to set different staleTime and gcTime per resource. The key is the first static path segment (e.g. tasks for /api/v1/tasks/{id}):
{ "input_openapi": "./openapi.json", "output": "./src/api", "stale_time": 30000, "gc_time": 300000, "overrides": { "platforms": { "stale_time": 86400000 }, "settings": { "stale_time": 5000, "gc_time": 60000 } }}Non-overridden resources use the global stale_time and gc_time values.
Use --config to point at different config files per vendor API. Both generators accept the same flag and resolve relative paths from the config file’s directory:
npx openapi-gen --config ./config/payments.config.jsonnpx openapi-react-query --config ./config/payments.config.json
npx openapi-gen --config ./config/inventory.config.jsonnpx openapi-react-query --config ./config/inventory.config.jsonWhen a resource has exactly one GET with path parameters, the key factory uses the canonical detail(id) shape:
// Spec: GET /tasks/{id} onlyexport const taskKeys = { all: () => ['tasks'] as const, list: (params?) => ['tasks', 'list', params] as const, detail: (id: string) => ['tasks', id] as const,}When a resource has more than one GET with path parameters (e.g. /items/{id} and /items/{id}/usage), the key factory uses operation-name-derived segments to prevent cache key collisions:
// Spec: GET /items/{id} operationId: getItemById// GET /items/{id}/usage operationId: getItemUsageexport const itemKeys = { all: () => ['items'] as const, getItemById: (id: string) => ['items', 'getItemById', id] as const, getItemUsage: (id: string) => ['items', 'getItemUsage', id] as const,}Without the operation-name segment both keys would resolve to ['items', id], causing React Query to treat them as the same cache entry.
Query params become required (no ?) in the generated hook signature when the spec marks any query parameter required: true. Optional params keep the params? form.
// Spec marks 'cursor' as required: trueexport function useListItems( params: Parameters<typeof listItems>[0], // required, no ? options?: ...)Every generated hook types errors as ApiError. This class is generated by @codewithagents/openapi-gen into your client.ts output file:
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) ) { ... }}const { data, isError, error } = useGetTask(taskId)
if (isError) { // error is typed ApiError console.error(error.status, error.body) return <p>Failed to load task ({error.status})</p>}Pass an onError callback in options:
const create = useCreateTask({ onError: (err) => { // err is typed ApiError console.error('Status:', err.status) console.error('Body:', err.body) },})@codewithagents/api-errorsApiError is the direct input to @codewithagents/api-errors, which maps backend validation errors to form field errors. Mutation errors are already typed correctly:
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 })) },})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 |
The example below uses Vitest, React Testing Library, and MSW for HTTP mocking. Only the last two are new additions if you are not already using them.
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')})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')})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:
// 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 ( <HydrationBoundary state={dehydrate(queryClient)}> <TaskList /> </HydrationBoundary> )}// app/tasks/TaskList.tsx ('use client')'use client'import { useListTasks } from '@/api/hooks'
export default function TaskList() { const { data } = useListTasks() return ( <ul> {data?.items.map((t) => ( <li key={t.id}>{t.title}</li> ))} </ul> )}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 for the full setup.
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:
/** @deprecated */export function useGetLegacyTask(id: string | undefined | null, options?: ...) { ... }Types and fetch client
@codewithagents/openapi-gen generates the typed fetch client and models that these hooks wrap. Run it first.
Server interface
@codewithagents/openapi-server generates a typed service interface and optional router from the same spec.
Form error mapping
@codewithagents/api-errors maps ApiError responses from mutation hooks directly to form field errors.
Check two things:
auto_invalidate must be set to true in your config file.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.
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 above).