Orval generates typed API clients with React Query hooks from OpenAPI specs — orval.config.ts with defineConfig controls everything. output: { mode: "tags-split", target: "src/api/endpoints/", client: "react-query" } generates one file per tag with useGetUsers, useCreateUser hooks. output.client: "axios" generates Axios clients; output.client: "fetch" generates fetch clients. output.override.mutator injects a custom Axios instance with auth interceptors. output.override.operations.createUser.mutator overrides a specific operation. output: { client: "zod", target: "src/api/validations.ts" } generates Zod schemas for runtime validation. MSW mocks: output.mock: true generates handlers for setupServer. orval --watch or generate: { watch: true } auto-regenerates on spec changes. Multiple outputs from one config: separate files for schemas, hooks, and MSW mocks. Claude Code generates Orval configs, React Query hooks, custom Axios mutators, and MSW mock setups.
CLAUDE.md for Orval
## Orval Stack
- Version: orval >= 7.3
- Config: orval.config.ts at project root using defineConfig
- Generate: npx orval (reads orval.config.ts) — generates hooks + types + optionally mocks
- Client react-query: generates useGetResource, useCreateResource hooks with useQuery/useMutation
- Custom mutator: output.override.mutator with custom axios instance for auth headers
- Zod output: separate output entry with client: "zod" for runtime validation schemas
- MSW mocks: output.mock: true generates handlers for msw/node testing
- Watch: npx orval --watch or generate: { watch: true }
Orval Config
// orval.config.ts — Orval code generation config
import { defineConfig } from "orval"
export default defineConfig({
// ── Primary: React Query hooks ──────────────────────────────────────────
api: {
input: {
target: process.env.OPENAPI_URL ?? "./docs/openapi.yaml",
validation: true,
},
output: {
mode: "tags-split", // One file per OpenAPI tag
target: "src/api/endpoints",
schemas: "src/api/model", // Shared type definitions
client: "react-query",
httpClient: "axios",
override: {
mutator: {
// Custom axios instance with auth interceptors
path: "src/lib/axios.ts",
name: "customAxios",
},
query: {
useQuery: true,
useInfiniteQuery: true,
useMutation: true,
useSuspenseQuery: true,
signal: true, // AbortSignal support
},
// Per-operation overrides
operations: {
// Mark specific endpoints as needing refresh on mutation
createPost: {
query: {
useInfiniteQuery: false,
},
},
},
},
// Clean output directory before regenerating
clean: true,
prettier: true,
},
},
// ── Zod validation schemas ──────────────────────────────────────────────
apiZod: {
input: {
target: process.env.OPENAPI_URL ?? "./docs/openapi.yaml",
},
output: {
client: "zod",
target: "src/api/validations.ts",
override: {
coerceTypes: true,
},
},
},
// ── MSW mock handlers for tests ─────────────────────────────────────────
apiMocks: {
input: {
target: "./docs/openapi.yaml",
},
output: {
mode: "single",
target: "src/mocks/handlers.ts",
client: "react-query",
mock: true,
override: {
mock: {
useExamples: true, // Use OpenAPI examples as mock values
},
},
},
},
})
Custom Axios Mutator
// src/lib/axios.ts — Orval custom mutator with auth
import Axios, { type AxiosRequestConfig } from "axios"
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "/api"
export const axiosInstance = Axios.create({
baseURL: BASE_URL,
timeout: 30_000,
withCredentials: true,
})
// Attach auth token on every request
axiosInstance.interceptors.request.use((config) => {
const token = typeof window !== "undefined"
? localStorage.getItem("access_token")
: null
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Handle 401 — refresh token or redirect
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const refreshToken = localStorage.getItem("refresh_token")
const { data } = await Axios.post<{ accessToken: string }>(`${BASE_URL}/auth/refresh`, {
refreshToken,
})
localStorage.setItem("access_token", data.accessToken)
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`
return axiosInstance(originalRequest)
} catch {
localStorage.removeItem("access_token")
localStorage.removeItem("refresh_token")
window.location.href = "/sign-in"
}
}
return Promise.reject(error)
},
)
// This is the mutator function Orval calls for each request
export const customAxios = <T>(config: AxiosRequestConfig): Promise<T> => {
const source = Axios.CancelToken.source()
const promise = axiosInstance({ ...config, cancelToken: source.token }).then(
({ data }) => data,
)
// Allow Orval to cancel in-flight requests
;(promise as any).cancel = () => {
source.cancel("Query was cancelled by Orval")
}
return promise
}
export default customAxios
Using Generated Hooks
// components/posts/PostList.tsx — using Orval-generated React Query hooks
"use client"
// Orval generates these from OpenAPI spec
import {
useGetApiPosts,
useCreateApiPost,
useDeleteApiPostsId,
type Post,
type CreatePostBody,
} from "@/api/endpoints/posts"
import { useQueryClient } from "@tanstack/react-query"
import { getGetApiPostsQueryKey } from "@/api/endpoints/posts"
import { useState } from "react"
export function PostList() {
const [page, setPage] = useState(1)
const queryClient = useQueryClient()
// Generated hook — fully typed query params and response
const { data, isLoading, error } = useGetApiPosts({
page,
pageSize: 10,
published: true,
})
const { mutateAsync: createPost, isPending } = useCreateApiPost({
mutation: {
onSuccess: () => {
// Invalidate posts list after creation
queryClient.invalidateQueries({ queryKey: getGetApiPostsQueryKey() })
},
},
})
const { mutateAsync: deletePost } = useDeleteApiPostsId({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getGetApiPostsQueryKey() })
},
},
})
const handleCreate = async (input: CreatePostBody) => {
await createPost({ data: input })
}
const handleDelete = async (id: string) => {
await deletePost({ id })
}
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {(error as any).message}</div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-lg">Posts ({data?.total ?? 0})</h2>
</div>
<div className="divide-y border rounded-xl">
{(data?.posts ?? []).map((post: Post) => (
<div key={post.id} className="flex items-center gap-3 p-4">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{post.title}</p>
<p className="text-xs text-muted-foreground">{post.slug}</p>
</div>
<button
onClick={() => handleDelete(post.id)}
className="text-xs text-destructive hover:underline"
>
Delete
</button>
</div>
))}
</div>
{/* Pagination */}
<div className="flex justify-center gap-2">
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}
className="px-3 py-1.5 text-sm rounded border disabled:opacity-50">
Prev
</button>
<button onClick={() => setPage(p => p + 1)} disabled={!data || page * 10 >= data.total}
className="px-3 py-1.5 text-sm rounded border disabled:opacity-50">
Next
</button>
</div>
</div>
)
}
MSW Test Setup
// src/mocks/setup.ts — MSW with Orval-generated handlers
import { setupServer } from "msw/node"
// Generated by Orval from OpenAPI spec
import { handlers } from "./handlers"
export const server = setupServer(...handlers)
// vitest.setup.ts:
// import { server } from "./src/mocks/setup"
// beforeAll(() => server.listen({ onUnhandledRequest: "error" }))
// afterEach(() => server.resetHandlers())
// afterAll(() => server.close())
// scripts/generate-api.ts — regenerate on spec change
// package.json:
// {
// "scripts": {
// "generate:api": "orval",
// "generate:api:watch": "orval --watch",
// "prebuild": "npm run generate:api"
// }
// }
// .github/workflows/generate-api.yml (ci auto-regen):
// on:
// push:
// paths: ["docs/openapi.yaml"]
// jobs:
// generate:
// runs-on: ubuntu-latest
// steps:
// - uses: actions/checkout@v4
// - uses: actions/setup-node@v4
// with: { node-version: '20' }
// - run: npm ci
// - run: npm run generate:api
// - uses: stefanzweifel/git-auto-commit-action@v5
// with:
// commit_message: "chore: regenerate API client"
// file_pattern: "src/api/**"
For the openapi-ts (@hey-api) alternative when a more flexible plugin system with better tree-shaking, a wider range of output shapes, and fetch-native clients without an Axios dependency are preferred — openapi-ts has a cleaner plugin architecture while Orval has better React Query hook generation with more options per endpoint, see the openapi-ts guide. For the Zodios alternative when hand-authoring a typed contract in TypeScript code (rather than generating from a YAML spec) and runtime Zod validation at the HTTP boundary is preferred over code generation — Zodios is better when there’s no OpenAPI spec or the spec is not the source of truth, see the Zodios guide. The Claude Skills 360 bundle includes Orval skill sets covering React Query generation, Zod schemas, and MSW mock handlers. Start with the free tier to try OpenAPI client generation.