# Frontend overview

> The AppointMe frontend is a React 19 + TypeScript SPA built with Vite+, TanStack Query and shadcn/ui. In production the .NET 10 API serves it from the same origin.

Source page: https://appointme.dev/docs/frontend/overview/

* **Stack:** React 19, TypeScript 6, Tailwind CSS 4
* **Toolchain:** Vite+ 1.4, run as `vp`
* **Server state:** TanStack Query, through a generated client
* **Served by:** The API’s `wwwroot` in production

## The stack

**`package.json` declares caret ranges (Vite+ is pinned exactly), and `package-lock.json` pins the exact versions below.**

| Package                            | Locked version | Used for                                                                 |
| ---------------------------------- | -------------- | ------------------------------------------------------------------------ |
| `react`, `react-dom`               | 19.3.1         | The UI                                                                   |
| `typescript`                       | 6.0.3          | Editor tooling. Type-checking runs inside Vite+ (tsgolint), not `tsc`.   |
| `vite-plus`                        | 1.4.0          | Dev server, build, Oxlint and Oxfmt. It bundles Vite 8.3.5.              |
| `tailwindcss`, `@tailwindcss/vite` | 4.3.3          | Styling                                                                  |
| `@tanstack/react-query`            | 5.104.5        | Server state, through the [generated client](/docs/frontend/api-client/) |
| `@tanstack/react-table`            | 9.3.1          | The shared `DataTable`                                                   |
| `react-router`                     | 8.5.0          | Routing, with `createBrowserRouter`                                      |
| `react-hook-form`, `zod`           | 7.91.0, 4.6.7  | Forms and validation, joined by `@hookform/resolvers`                    |
| `@fullcalendar/react`              | 7.1.3          | The appointments calendar                                                |
| `recharts`                         | 3.10.1         | Dashboard charts                                                         |
| `temporal-polyfill`                | 1.0.6          | Date arithmetic in the company’s time zone                               |
| `axios`, `orval`                   | 1.20.0, 8.45.0 | HTTP, and the generator for the typed client                             |

Smaller pieces: `nuqs` 2.10.2 keeps search and filter state in the URL, `sonner` 2.0.8 shows toasts, and `lucide-react` 1.61.0 supplies icons. `.node-version` pins Node 24, and `engines` accepts `^22.18.0 || ^24.11.0 || >=26.0.0`.

## Folder structure

**Pages live in `src/app`, one folder per feature, and shared building blocks live in `src/components`.**

* src/AppointMe.Frontend/

  * orval.config.ts # generates src/api from the API’s obj/openapi.json

  * vite.config.ts # plugins, dev proxy, lint and format rules

  * components.json # shadcn/ui settings

  * .env.development # VITE_API_PROXY_TARGET for runs without Aspire

  * src/

    * main.tsx # createRoot and error reporting

    * app.tsx # QueryClientProvider, ErrorBoundary, RouterProvider

    * api/ # generated by orval, never edited by hand

      * …

    * app/

      * appointments/ # the reference slice: a page plus one folder per action

        * …

      * customers/

        * …

      * layouts/ # Layout, header, sidebar, nav-data.ts

        * …

      * router/ # router.tsx, app-shell.tsx, breadcrumb and nav hooks

        * …

      * …

    * components/

      * ui/ # vendored shadcn/ui primitives and the DataTable

        * …

      * auth/ # current user, current company, permissions, Can

        * …

      * …

    * lib/ # axios.ts, query-client.ts, logout.ts, storage-keys.ts, utils.ts

      * …

A feature folder mirrors a backend [vertical slice](/docs/concepts/vertical-slices/): the route page sits at its root, and each user action gets a verb-first subfolder (`schedule-appointment/`, `cancel-appointment/`). Feature and action folders each have an `index.ts` barrel, and filenames are kebab-case.

## Routing and layouts

**One route table, in `app/router/router.tsx`. Every route sits under `AppShell`, which loads the current user, and a state guard admits the user to exactly one route group.**

*Figure 1. Each request for a page passes the same shell, then lands in one route group.*

1. **RootLayout** — `AppProviders`: the nuqs adapter and the theme

2. **AppShell** — `CurrentUserProvider` loads `GET /api/v1/me`

3. **StateRouter** — redirects unless the user is in an allowed state

4. Then, in parallel:

   * **AnonymousRoutes** — `/auth/login`, `/auth/signup`
   * **Onboarding, Invitation routes** — `/onboarding`, `/invitations`
   * **AppRoutes** — company providers, then `Layout`

| Route group        | Paths                                                                                                               | Who gets in                                            |
| ------------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `AnonymousRoutes`  | `/auth/login`, `/auth/signup`, `/auth/verify-email/:email?`                                                         | Signed-out users                                       |
| `OnboardingRoutes` | `/onboarding`                                                                                                       | Signed in, no company, no pending invitations          |
| `InvitationRoutes` | `/invitations`                                                                                                      | Signed in, no company, at least one pending invitation |
| `AppRoutes`        | `/` (redirects to `/appointments`), `/appointments`, `/dashboard`, `/team`, `/customers/*`, `/settings/permissions` | Signed in, with at least one company                   |

The main app wraps `Layout` in `CurrentCompanyProvider`, a `Suspense` spinner, `UserAccessProvider` and `ModalDialogProvider`. `Layout` renders the sidebar, the header (breadcrumbs, company selector, theme toggle), the page in an `<Outlet />` and the footer. Breadcrumbs and the active sidebar item come from each route’s `handle`:

src/AppointMe.Frontend/src/app/router/router.tsx

```tsx
                                    {
                                        path: 'customers',
                                        handle: { breadcrumb: 'Customers', navId: 'customers' },
                                        children: [
                                            { index: true, element: <Customers /> },
                                            {
                                                path: 'new',
                                                element: <CreateCustomer />,
                                                handle: { breadcrumb: 'New customer' },
                                            },
                                            {
                                                path: ':id',
                                                element: <CustomerProfile />,
                                                handle: {
                                                    breadcrumb: 'Profile',
                                                },
                                            },
                                        ],
                                    },
```

`useBreadcrumbs()` turns every matched `breadcrumb` into a crumb, and `useActiveNavIds()` collects the matched `navId`s for the sidebar. How the user states are worked out is on [Auth & tenancy in the SPA](/docs/frontend/auth-and-tenancy/).

## Data, state and forms

**Server state lives in TanStack Query, through hooks generated from the API’s OpenAPI document. There’s no global client-state library: React context, one small external store and the URL hold the rest.**

| State                              | Where it lives                                  | Read it with                                                                  |
| ---------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------- |
| API data                           | Generated hooks in `src/api/client.ts`          | `useGetCustomers(params)`, `useRegisterCustomer()`                            |
| Current user, company, permissions | React context in `components/auth`              | `useCurrentUser()`, `useCurrentCompany()`, `useUserAccess()`                  |
| Selected company id                | `currentCompanyStore`, backed by `localStorage` | `useCurrentCompanyId()`; the axios mutator calls `currentCompanyStore.get()`  |
| Search and filters                 | The query string, through `nuqs`                | `useQueryState('search', parseAsString.withDefault(''))`                      |
| Dialogs                            | `ModalDialogProvider`                           | `modalDialog.open(props => <EditCustomerDialog {...props} customerId={…} />)` |

The shared `QueryClient` turns off refetch on window focus and on reconnect. It shows a global toast only for failures that aren’t the caller’s fault:

src/AppointMe.Frontend/src/lib/query-client.ts

```ts
// Network errors and 5xx responses get the global toast — they're systemic and
// the message is always "something on our side broke". 4xx responses are
// contextual (validation, not-found, conflict, …) and stay the call site's job.
const isServerError = (error: unknown): boolean => {
    if (!isAxiosError(error)) {
        return true;
    }
    const status = error.response?.status;
    return status === undefined || status >= 500;
};
```

Forms use `react-hook-form` with a `zod` schema through `zodResolver`, and render each input in a `Controller` with the shadcn `Field`, `FieldLabel` and `FieldError` components. The schema sits in the same file as the form that uses it. [Add a page](/docs/frontend/add-a-page/) walks through the pattern from `create-customer.tsx`.

## UI components and theming

**The shadcn/ui components are vendored into `src/components/ui`, so they’re your code to edit. Colors are CSS variables in `src/index.css`, and a `.dark` class on `<html>` switches them.**

| Piece                                                      | Where                                        | Notes                                                                                                                               |
| ---------------------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| shadcn/ui primitives                                       | `src/components/ui/`                         | `new-york` style, `neutral` base color, CSS variables on (`components.json`). Built on Radix UI.                                    |
| `DataTable`, `DataTablePagination`, `DataTableViewOptions` | `src/components/ui/data-table*.tsx`          | TanStack Table 9, typed against one `dataTableFeatures` set                                                                         |
| Theme tokens                                               | `src/index.css`                              | oklch variables under `:root` and `.dark`, exposed to Tailwind with `@theme inline`                                                 |
| Theme switch                                               | `src/components/theme/`                      | `ThemeProvider` puts `light` or `dark` on `<html>` and saves it as `appointme:ui-theme`. The header toggle and the `D` key flip it. |
| Calendar look                                              | `src/components/ui/event-calendar-views.tsx` | FullCalendar’s shadcn registry component                                                                                            |
| `cn()`                                                     | `src/lib/utils.ts`                           | `clsx` plus `tailwind-merge`                                                                                                        |

Use theme tokens (`bg-background`, `text-muted-foreground`, `border-border`) rather than raw colors, so dark mode follows. Oxlint’s `react/only-export-components` rule is switched off for `components/ui/**`, because shadcn files ship variants and hooks next to their components.

## Scripts, dev server and production

**In development the Vite+ dev server serves the SPA on `https://localhost:5173` and proxies API traffic. In production the API serves the built files itself.**

| Script                            | Runs                                    | Notes                                                        |
| --------------------------------- | --------------------------------------- | ------------------------------------------------------------ |
| `npm run dev`                     | `vp dev`                                | HTTPS through `vite-plugin-mkcert`                           |
| `npm run build`                   | `vp build`                              | Bundles only. No lint, no type-check.                        |
| `npm run check`                   | `vp check`                              | Format check, Oxlint and type-check in one pass. CI runs it. |
| `npm run lint` / `npm run format` | `vp lint .` / `vp fmt`                  | Lint plus type-check / Oxfmt                                 |
| `npm run generate:api`            | `dotnet build` of the API, then `orval` | See [Typed API client](/docs/frontend/api-client/)           |

Lint and format rules live in the `lint` and `fmt` blocks of `vite.config.ts`. There’s no `eslint.config.js` or `.prettierrc`.

|                | Development                                                | Production                                                              |
| -------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------- |
| Serves the SPA | The Vite+ dev server                                       | The API: `UseStaticFiles()` plus `MapFallbackToFile("index.html")`      |
| Started by     | Aspire’s `AddViteApp`, or `npm run dev`                    | `dotnet AppointMe.Api.dll` in the container                             |
| API calls      | `/api`, `/signin-oidc` and `/admin` are proxied to the API | Same origin, no proxy                                                   |
| Built by       | Not built: served from source                              | The Dockerfile’s `node:24-alpine` stage: `npm ci`, then `npm run build` |

Aspire hosts the frontend with `Aspire.Hosting.JavaScript`, pins it to port 5173 over HTTPS and hands it the API’s address:

src/AppointMe.Aspire/Program.cs

```cs
builder.AddViteApp(name: "appointme-frontend", appDirectory: "../AppointMe.Frontend")
    .WithNpm(installArgs: ["--no-save"])
    .WithEndpoint("http", endpoint =>
    {
        endpoint.Port = 5173;
        endpoint.UriScheme = "https";
    })
    .WithReference(appointmeApi)
    .WaitFor(appointmeApi);
```

The dev proxy prefers the address Aspire injects and falls back to `.env.development` (`https://localhost:7233`) when you run the API yourself:

src/AppointMe.Frontend/vite.config.ts

```ts
    const aspireApiHttps = env['services__appointme-api__https__0'];
    const aspireApiHttp = env['services__appointme-api__http__0'];
    const proxyTarget = aspireApiHttps || aspireApiHttp || env.VITE_API_PROXY_TARGET;
```

For production, the image builds the bundle in its first stage and copies `dist` into the API’s `wwwroot`:

src/AppointMe.Api/Dockerfile

```dockerfile
# Stage 3: runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app/publish ./
COPY --from=frontend /src/dist ./wwwroot
```

The build’s `APP_VERSION` argument becomes `VITE_APP_VERSION`, which the footer shows.

## Gotchas

| Symptom                                             | Cause                                                                                                                                                                     | Fix                                                                                       |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `npm run build` passes locally but CI fails         | `build` doesn’t lint or type-check                                                                                                                                        | Run `npm run check` before you push.                                                      |
| Editing a component reloads the whole page          | A `.tsx` file also exports a hook, a function or a non-primitive constant; Oxlint warns `react/only-export-components` (type exports and primitive constants are allowed) | Move the non-component export into a sibling `.ts` file.                                  |
| `package-lock.json` changes after `npm install`     | It was written by a different npm major than CI’s npm 11                                                                                                                  | Change dependencies on Node 24 and check with `npm ci`. Aspire installs with `--no-save`. |
| API calls fail when you run only `npm run dev`      | Nothing listens on the proxy target                                                                                                                                       | Start the API on `https://localhost:7233`, or run the whole stack with Aspire.            |
| Toasts don’t follow the dark-mode toggle            | The vendored `components/ui/sonner.tsx` reads `useTheme` from `next-themes`, which has no provider here, so it falls back to `system`                                     | Read the theme from `@/components/theme` in that file.                                    |
| `vp build` warns that a chunk is larger than 500 kB | Every page is imported eagerly into one bundle of about 1.7 MB                                                                                                            | Split heavy routes with React Router’s `lazy` route property if load time matters.        |

### Why does the API serve the SPA instead of a separate static host?

Serving `index.html` and the bundle from the API keeps the browser on one origin. The `appointme.auth` session cookie is `SameSite=Lax`, the antiforgery cookie is read by script on the same host, and the API configures no CORS policy, because it doesn’t need one.

It also means one container to build and deploy. The dev server recreates the same arrangement with its proxy, which is why `/signin-oidc` is proxied too: the OIDC callback has to land on the origin the browser is on.

### Why Vite+ instead of Vite, ESLint and Prettier?

One tool, `vp`, replaces the separate Vite, ESLint, Prettier and `tsc` setup, and all of its config sits in `vite.config.ts`. `npm run check` formats, lints and type-checks in one pass, and that one command is what CI runs.

The cost is a younger toolchain. Vite+ is pinned in three `package.json` entries (`vite-plus`, and `vite` in both `devDependencies` and `overrides`) that have to move together, so Dependabot leaves it out.

## Next

* [Typed API client](/docs/frontend/api-client/) (Concept)
* [Auth & tenancy in the SPA](/docs/frontend/auth-and-tenancy/) (Concept)
* [Add a frontend page](/docs/frontend/add-a-page/) (Guide)
