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

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 wwwroot in production

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.

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.

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.

RootLayoutAppProviders: the nuqs adapter and the theme
AppShellCurrentUserProvider loads GET /api/v1/me
StateRouterredirects unless the user is in an allowed state
AnonymousRoutes/auth/login, /auth/signup
Onboarding, Invitation routes/onboarding, /invitations
AppRoutescompany providers, then Layout
Figure 1. Each request for a page passes the same shell, then lands in one route group.
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.tsxView source ↗
{
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.

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.tsView source ↗
// 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.

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.

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:

src/AppointMe.Aspire/Program.csView source ↗
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.tsView source ↗
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/DockerfileView source ↗
# 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.

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.