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.
- 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
wwwrootin production
The stack
Section titled “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 |
@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
Section titled “Folder structure”Pages live in src/app, one folder per feature, and shared building blocks live in src/components.
Directorysrc/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
Directorysrc/
- main.tsx # createRoot and error reporting
- app.tsx # QueryClientProvider, ErrorBoundary, RouterProvider
Directoryapi/ # generated by orval, never edited by hand
- …
Directoryapp/
Directoryappointments/ # the reference slice: a page plus one folder per action
- …
Directorycustomers/
- …
Directorylayouts/ # Layout, header, sidebar, nav-data.ts
- …
Directoryrouter/ # router.tsx, app-shell.tsx, breadcrumb and nav hooks
- …
- …
Directorycomponents/
Directoryui/ # vendored shadcn/ui primitives and the DataTable
- …
Directoryauth/ # current user, current company, permissions, Can
- …
- …
Directorylib/ # axios.ts, query-client.ts, logout.ts, storage-keys.ts, utils.ts
- …
A feature folder mirrors a backend vertical slice: 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
Section titled “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.
AppProviders: the nuqs adapter and the themeCurrentUserProvider loads GET /api/v1/me/auth/login, /auth/signup/onboarding, /invitationsLayout| 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:
{ 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 navIds for the sidebar. How the user states are worked out is on Auth & tenancy in the SPA.
Data, state and forms
Section titled “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:
// 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 walks through the pattern from create-customer.tsx.
UI components and theming
Section titled “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
Section titled “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 |
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:
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:
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:
# Stage 3: runtimeFROM mcr.microsoft.com/dotnet/aspnet:10.0WORKDIR /appCOPY --from=build /app/publish ./COPY --from=frontend /src/dist ./wwwrootThe build’s APP_VERSION argument becomes VITE_APP_VERSION, which the footer shows.
Gotchas
Section titled “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?2 min · optional
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?1 min · optional
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.