Skip to content
StarLive demo →
ConceptChecked against main · 31 Oct 20268 min read

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 by dotnet build
Output
src/api/client.ts and client.schemas.ts
Regenerate
npm run generate:api

The API build writes the OpenAPI document, orval turns it into hooks and types, and Oxfmt formats the result. Nothing has to be running.

dotnet buildboots the API in the Codegen environment
obj/openapi.jsona build artifact, ignored by git
orvalreads orval.config.ts, then runs vp fmt
src/api/hooks and types, committed
Figure 1. From a C# endpoint to a typed hook, without a running server.
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:

src/AppointMe.Api/AppointMe.Api.csprojView source ↗
<!-- 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:

src/AppointMe.Api/Program.csView source ↗
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,
});

Split output, axios as the transport, React Query hooks with suspense variants, one custom mutator, and a clean folder on every run.

src/AppointMe.Frontend/orval.config.tsView source ↗
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

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.

src/AppointMe.Frontend/src/lib/axios.tsView source ↗
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);
},
);
src/AppointMe.Frontend/src/lib/axios.tsView source ↗
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.

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:

src/AppointMe.Frontend/src/api/client.tsView source ↗
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:

src/AppointMe.Frontend/src/app/customers/customers.tsxView source ↗
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:

src/AppointMe.Frontend/src/app/customers/create-customer.tsxView source ↗
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>.

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:

src/AppointMe.Frontend/package.jsonView source ↗
"generate:api": "dotnet build ../AppointMe.Api/AppointMe.Api.csproj -c Release --nologo -v q && orval"
Terminal window
cd src/AppointMe.Frontend
npm run generate:api
git diff --stat src/api/
npx vp check --no-fmt --no-lint

The 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.

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.