Typed API client
AppointMe generates a typed TanStack Query client for React from the .NET 10 API's OpenAPI document with orval. Every dotnet build writes that document, so no running backend is needed.
- Generator
- orval 8.45, React Query over axios
- Input
obj/openapi.json, written bydotnet build- Output
src/api/client.tsandclient.schemas.ts- Regenerate
npm run generate:api
How the client is generated
Section titled “How the client is generated”The API build writes the OpenAPI document, orval turns it into hooks and types, and Oxfmt formats the result. Nothing has to be running.
Codegen environmentorval.config.ts, then runs vp fmt| Piece | Where | What it does |
|---|---|---|
Microsoft.Extensions.ApiDescription.Server |
AppointMe.Api.csproj |
Runs the app’s startup during the build and writes the document to obj/openapi.json |
InvalidateOpenApiDocumentsOnReferenceChange |
AppointMe.Api.csproj |
Forces a new document when a module assembly changes, since the endpoints live in the modules |
CodeGenerationDetection |
AppointMe.Api/Wolverine |
Spots the build-time run (entry assembly GetDocument.Insider) and Wolverine’s codegen command |
appsettings.Codegen.json |
AppointMe.Api |
Placeholder settings. The run skips Hangfire and migrations, and Wolverine runs MediatorOnly. |
PermissionSchemaTransformer |
AppointMe.Api/OpenApi |
Writes every permission code into the document as an enum, which becomes the Permission type |
orval.config.ts |
AppointMe.Frontend |
Input, output and the custom mutator |
The output location is two MSBuild properties:
<!-- Build-time OpenAPI document (Microsoft.Extensions.ApiDescription.Server): every build writes obj/openapi.json (a build artifact, never committed), and the frontend's orval generates the API client from it. --> <OpenApiDocumentsDirectory>obj</OpenApiDocumentsDirectory> <OpenApiGenerateDocumentsOptions>--file-name openapi</OpenApiGenerateDocumentsOptions>Generating the document starts the host, so Program.cs swaps in the Codegen environment first:
var isCodegen = CodeGenerationDetection.IsRunningGeneration();
// Code-generation runs (Wolverine's `codegen` command and the build-time OpenAPI document) boot the app without its// real configuration; the Codegen environment's appsettings.Codegen.json supplies placeholder values.var builder = WebApplication.CreateBuilder(new WebApplicationOptions{ Args = args, EnvironmentName = isCodegen ? "Codegen" : null,});The orval config
Section titled “The orval config”Split output, axios as the transport, React Query hooks with suspense variants, one custom mutator, and a clean folder on every run.
export default defineConfig({ appointme: { input: { target: '../AppointMe.Api/obj/openapi.json', }, output: { target: './src/api/client.ts', mode: 'split', httpClient: 'axios', client: 'react-query', override: { query: { useSuspenseQuery: true, }, mutator: { path: 'src/lib/axios.ts', name: 'apiClient', }, }, clean: true, }, hooks: { afterAllFilesWrite: 'vp fmt', }, },| Option | Effect |
|---|---|
mode: 'split' |
Hooks and request functions go to client.ts, types to client.schemas.ts |
client: 'react-query' |
A query hook per GET, a mutation hook per POST, PUT, PATCH or DELETE, plus query-key helpers |
useSuspenseQuery: true |
Adds a use…Suspense variant next to each query hook |
mutator |
Every request goes through apiClient in src/lib/axios.ts |
clean: true |
Empties the output folder first, so hand edits are lost on the next run |
afterAllFilesWrite: 'vp fmt' |
Formats the output so npm run check passes |
The apiClient mutator
Section titled “The apiClient mutator”Every generated call goes through one function that adds the company header, and one axios instance that sends the cookie, echoes the XSRF token and redirects on 401.
export const axiosInstance = axios.create({ baseURL: '/', withCredentials: true, xsrfCookieName: 'XSRF-TOKEN', xsrfHeaderName: 'X-XSRF-TOKEN', withXSRFToken: true,});
axiosInstance.interceptors.response.use( response => response, error => { if (error?.response?.status === 401) { if (!globalThis.location.pathname.startsWith('/auth/login')) { globalThis.location.href = '/auth/login'; } } return Promise.reject(error); },);export const apiClient = async <T = unknown>(config: AxiosRequestConfig): Promise<T> => { const companyId = currentCompanyStore.get(); const headers = { // oxlint-disable-next-line typescript/no-misused-spread -- orval passes plain-object headers; an AxiosHeaders instance also keeps its values as own enumerable props ...config.headers, ...(companyId ? { 'X-Company-Id': companyId } : {}), };
const response: AxiosResponse<T> = await axiosInstance.request<T>({ ...config, headers }); return response.data;};| Setting | What it’s for | Read more |
|---|---|---|
baseURL: '/', withCredentials: true |
Same-origin calls that carry the appointme.auth session cookie |
Auth & tenancy |
xsrfCookieName, xsrfHeaderName, withXSRFToken |
Copies the XSRF-TOKEN cookie into an X-XSRF-TOKEN header |
Auth & tenancy |
| The 401 interceptor | Sends the browser to /auth/login, unless it’s already there |
Auth & tenancy |
X-Company-Id |
The company picked in the header, read from currentCompanyStore on every call |
Multi-tenancy |
apiClient returns response.data, so a hook’s data is the response body, already typed.
Using the generated hooks
Section titled “Using the generated hooks”Each endpoint becomes a request function, a query-key helper and a hook. Pages call the hook, and after a mutation they invalidate the key.
Hook names come from the endpoint’s .WithName(...), for example WithName(nameof(GetCustomers)):
| Endpoint | Generated | Called as |
|---|---|---|
GET /api/v1/customers |
getCustomers, getGetCustomersQueryKey, useGetCustomers, useGetCustomersSuspense |
useGetCustomers(params, { query }) |
POST /api/v1/customers |
registerCustomer, useRegisterCustomer |
mutateAsync({ data }) |
PUT /api/v1/customers/{id} |
updateCustomer, useUpdateCustomer |
mutateAsync({ id, data }) |
DELETE /api/v1/customers/{id} |
deleteCustomer, useDeleteCustomer |
mutateAsync({ id }) |
The request function and its key, as orval writes them:
export const getCustomers = (params?: GetCustomersParams, signal?: AbortSignal) => { return apiClient<GetCustomersResponse>({ url: `/api/v1/customers`, method: 'GET', params, signal });};
export const getGetCustomersQueryKey = (params?: GetCustomersParams) => { return [`/api/v1/customers`, ...(params ? [params] : [])] as const;};A page passes query parameters and React Query options:
const { data: response, isLoading, isFetching, } = useGetCustomers( { search: debouncedSearch, page: pagination.pageIndex + 1, limit: pagination.pageSize, }, { query: { placeholderData: keepPreviousData } }, );A form calls the mutation, then invalidates the list. getGetCustomersQueryKey() without parameters matches every cached page of the list, because React Query matches keys by prefix:
const onSubmit = async (data: CreateCustomerFormValues) => { await registerCustomer({ data: { firstName: data.firstName, lastName: data.lastName || null, email: data.email || null, dateOfBirth: data.dateOfBirth || null, gender: data.gender ?? null, }, }); await queryClient.invalidateQueries({ queryKey: getGetCustomersQueryKey() }); toast.success('Customer created successfully'); void navigate('/customers'); };Most pages use the plain hooks and render their own loading state. CurrentUserProvider and UserAccessProvider use the Suspense variants, and the router wraps them in <Suspense>.
Regenerate after a contract change
Section titled “Regenerate after a contract change”Run npm run generate:api in the same change as any edit to an endpoint, route, verb, DTO or permission, and commit src/api/ with it.
The script builds the API in Release, so it doesn’t collide with an API that Aspire is running from bin/Debug, then runs orval:
"generate:api": "dotnet build ../AppointMe.Api/AppointMe.Api.csproj -c Release --nologo -v q && orval"cd src/AppointMe.Frontendnpm run generate:apigit diff --stat src/api/npx vp check --no-fmt --no-lintThe last command type-checks the call sites against the new client. It needs the .NET SDK, but no database, identity provider or running API.
| Regenerate after | No need after |
|---|---|
| Adding, renaming or removing an endpoint | Changing a handler’s internals |
| Changing a request or response shape | Domain events and their handlers |
| Changing a route, HTTP verb or status code | EF Core configuration and migrations |
| Adding or renaming a permission | Tests |
src/api/ is tracked in git, and the generated files say “Do not edit manually”. obj/openapi.json isn’t: the root .gitignore excludes every obj/ folder. In Development the API also serves the same contract at /openapi/v1.json, but the client isn’t generated from it.
If you work with Claude Code, the repo’s /regenerate-api skill runs this procedure and reports the diff. See Working with AI agents.
Troubleshooting a stale client
Section titled “Troubleshooting a stale client”| Symptom | Cause | Fix |
|---|---|---|
| A new endpoint has no hook | The client wasn’t regenerated | Run npm run generate:api. If src/api/ doesn’t change, the endpoint isn’t in the document: open src/AppointMe.Api/obj/openapi.json and look for it. |
npx orval didn’t pick up your change |
orval alone reads the last build’s document | Run the full npm run generate:api, which builds first. |
| Type errors after regenerating | Call sites no longer match the contract | Run npx vp check --no-fmt --no-lint and fix each call site in the same change. |
Your edit to client.ts disappeared |
clean: true rewrites the folder on every run |
Change the backend instead, then regenerate. |
The build fails in GenerateOpenApiDocuments |
New startup code needs a database or another service | Gate it on CodeGenerationDetection.IsRunningGeneration(), the way Program.cs gates Hangfire. |
npm run check fails on src/api/ |
The files were edited, or orval’s vp fmt hook didn’t run |
Regenerate rather than fixing by hand. |
Why commit generated code?2 min · optional
The src/api/ diff is how a contract change shows up in code review: a renamed field or a new endpoint is visible next to the C# change that caused it.
It also keeps the frontend buildable on its own. The Dockerfile’s frontend stage copies only src/AppointMe.Frontend, runs npm ci and npm run build, and has no .NET SDK, so it can’t regenerate anything. The cost is that nothing checks that the committed client matches the API: CI doesn’t regenerate and diff it, so a forgotten generate:api only shows up as a runtime error.
Why a build-time document instead of the running API's /openapi/v1.json?1 min · optional
Generating from a running server needed the whole stack up and a workaround for its self-signed TLS certificate. The build-time document needs neither: every dotnet build of the API writes it, in CI as well as locally. The price is that startup code must tolerate the Codegen environment, which is what CodeGenerationDetection is for.