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

Source page: https://appointme.dev/docs/frontend/api-client/

* **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`

## 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.**

*Figure 1. From a C# endpoint to a typed hook, without a running server.*

1. **dotnet build** — boots the API in the `Codegen` environment
2. **obj/openapi.json** — a build artifact, ignored by git
3. **orval** — reads `orval.config.ts`, then runs `vp fmt`
4. **src/api/** — hooks and types, committed

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

```xml
    <!-- 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.cs

```cs
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

**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.ts

```ts
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

**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.ts

```ts
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.ts

```ts
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](/docs/frontend/auth-and-tenancy/)                            |
| `xsrfCookieName`, `xsrfHeaderName`, `withXSRFToken` | Copies the `XSRF-TOKEN` cookie into an `X-XSRF-TOKEN` header                    | [Auth & tenancy](/docs/frontend/auth-and-tenancy/)                            |
| The 401 interceptor                                 | Sends the browser to `/auth/login`, unless it’s already there                   | [Auth & tenancy](/docs/frontend/auth-and-tenancy/)                            |
| `X-Company-Id`                                      | The company picked in the header, read from `currentCompanyStore` on every call | [Multi-tenancy](/docs/concepts/multi-tenancy/#how-a-request-gets-its-company) |

`apiClient` returns `response.data`, so a hook’s `data` is the response body, already typed.

## 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:

src/AppointMe.Frontend/src/api/client.ts

```ts
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.tsx

```tsx
    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.tsx

```tsx
    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

**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.json

```json
        "generate:api": "dotnet build ../AppointMe.Api/AppointMe.Api.csproj -c Release --nologo -v q && orval"
```

```bash
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](/docs/guides/ai-assisted-development/).

## 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?

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?

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.

## Next

* [Auth & tenancy in the SPA](/docs/frontend/auth-and-tenancy/) (Concept)
* [Add a frontend page](/docs/frontend/add-a-page/) (Guide)
* [Add an endpoint](/docs/guides/add-an-endpoint/) (Guide)
