Both clients/admin and clients/dashboard are Vite-based React SPAs. Standard npm lifecycle, standard build output, no special tooling required.
Running locally
Via Aspire (recommended)
dotnet run --project src/Host/FSH.Starter.AppHostThis starts everything:
- API at https://localhost:7030 (http on 5030)
- Admin at http://localhost:5173
- Dashboard at http://localhost:5174
- Postgres at tcp/5432, Valkey at tcp/6379, MinIO at :9000
Vite HMR works through Aspire — edits to frontend files reload instantly in the browser.
Via npm (frontend-only)
cd clients/admin # or clients/dashboardnpm install # first time onlynpm run dev # http://localhost:5173 (dashboard: 5174)This requires the backend to be running separately (either via Aspire, docker-compose, or dotnet run against the API project).
The Vite dev server proxies API paths to the backend — http://localhost:5030 for admin, https://localhost:7030 for the dashboard by default. To point at a different backend, set VITE_API_BASE_URL when starting the dev server; it only steers the dev proxy target, it is never compiled into a build.
The npm scripts
// clients/admin/package.json (dashboard is identical apart from ports){ "scripts": { "dev": "vite --port 5173", // dev server with HMR "build": "tsc -b && vite build", // typecheck + production bundle "preview": "vite preview --port 4173",// serve the production bundle locally "lint": "eslint .", "test:e2e": "playwright test", // route-mocked E2E suite "test:e2e:ui": "playwright test --ui" }}The build script runs the TypeScript project-references compiler first (tsc -b) so type errors fail the build. Vite handles transpilation + bundling.
Production build
cd clients/adminnpm run buildOutput:
clients/admin/dist/├── index.html├── assets/│ ├── index-{hash}.js│ ├── index-{hash}.css│ └── ...└── ...dist/ is a static site — pure HTML / JS / CSS. No server runtime required.
Runtime configuration (/config.json)
Vite hardcodes VITE_* env vars into the bundle at build time. That’s fine for static URLs, but you usually want one built image that runs against multiple environments (dev, staging, prod) with different API URLs.
The kit’s solution: each app fetches /config.json once at boot — before React mounts — and reads every environment-dependent value from it. In dev, Vite serves public/config.json; in production, the container renders it from environment variables at startup.
// clients/admin/public/config.json (dev defaults){ "apiBase": "", // "" = same-origin (dev proxy / reverse proxy) "defaultTenant": "root", "dashboardUrl": "http://localhost:5174", // admin only — impersonation handoff target "inactivityIdleMs": 600000, "inactivityWarningMs": 60000}The dashboard’s variant swaps dashboardUrl for a demoMode flag (login-page demo-account picker).
The container pattern
clients/{app}/Dockerfile is a two-stage build — node:22-alpine runs npm ci && npm run build, then nginx:alpine serves dist/. The entrypoint renders the runtime config before starting Nginx:
# clients/{app}/docker/docker-entrypoint.sh: "${FSH_API_URL:?FSH_API_URL is required (e.g. https://api.example.com)}": "${FSH_DEFAULT_TENANT:=root}"
envsubst < /usr/share/nginx/html/config.json.template \ > /usr/share/nginx/html/config.json
exec nginx -g 'daemon off;'{ "apiBase": "${FSH_API_URL}", "defaultTenant": "${FSH_DEFAULT_TENANT}", "dashboardUrl": "${FSH_DASHBOARD_URL}"}At deploy time, set the env vars on the container:
docker run -e FSH_API_URL=https://api.example.com -e FSH_DASHBOARD_URL=https://app.example.com fsh-adminThe same image runs everywhere; only env vars differ.
Reading runtime config in code
// main.tsx awaits this before mounting Reactawait loadRuntimeConfig(); // fetch("/config.json", { cache: "no-store" })
// everywhere elseimport { env } from "@/env";env.apiBase; env.defaultTenant; env.inactivityIdleMs;env.* throws if read before the config loads — a deliberate fail-fast so a misordered import surfaces immediately instead of silently using the wrong API URL.
Nginx configuration for SPAs
The kit’s actual file, clients/{app}/docker/nginx.conf:
server { listen 80; server_name _; root /usr/share/nginx/html; index index.html;
# Long-lived caching for hashed asset bundles location ~* \.(?:js|css|woff2?|png|jpg|jpeg|svg|ico|webp)$ { expires 1y; add_header Cache-Control "public, immutable"; }
# Runtime config — must be re-fetched on every deploy location = /config.json { add_header Cache-Control "no-store"; add_header X-Content-Type-Options "nosniff"; }
# SPA fallback with security headers location / { try_files $uri /index.html; add_header X-Frame-Options "DENY"; add_header X-Content-Type-Options "nosniff"; add_header Referrer-Policy "strict-origin-when-cross-origin"; }
# Block dotfiles location ~ /\. { deny all; }}Deploying to a CDN
The kit’s frontends are CDN-friendly. Three deployment shapes:
Vercel / Netlify
Drag-drop the dist/ folder, or connect the repo and configure:
- Build command:
cd clients/admin && npm install && npm run build - Output directory:
clients/admin/dist
For runtime config, ship a per-environment config.json alongside the build output (it’s just a static file), or generate it in the build command.
S3 + CloudFront
npm run build, thenaws s3 sync dist/ s3://fsh-admin-bucket/- CloudFront distribution in front, with a default-root-object of
index.html+ a custom error response that servesindex.htmlfor 404 (so SPA routing works). - Set cache TTLs: no-store on
config.json, short onindex.html, long on the hashed assets.
Cloudflare Pages
Connect the repo, configure build command + output directory. Cloudflare’s Workers can inject runtime env via Pages Functions if needed.
Testing
Both apps ship Playwright E2E suites — fully route-mocked (no live backend required) with seeded JWTs, so they run fast and deterministic in CI:
npm run test:e2e # headless runnpm run test:e2e:ui # Playwright's interactive UI modeTests live under clients/{app}/tests/. There is no unit-test runner (Vitest) wired — the coverage strategy is E2E against mocked routes plus the backend’s own xUnit + integration suites.
Linting
ESLint is wired (flat config at clients/{app}/eslint.config.js, extending the React + TypeScript + a11y rule sets):
npm run lint # reportnpm run lint -- --fix # auto-fixThe dev server proxy
Vite proxies API paths to the backend so you don’t deal with CORS during development:
// clients/dashboard/vite.config.ts (excerpt)server: { port: 5174, strictPort: true, proxy: { // ws: true forwards the WebSocket upgrade used by SignalR's hub "/api": { target: apiBase, changeOrigin: true, secure: false, ws: true }, "/health": { target: apiBase, changeOrigin: true, secure: false }, "/openapi": { target: apiBase, changeOrigin: true, secure: false }, "/scalar": { target: apiBase, changeOrigin: true, secure: false }, },},With this in place, relative URLs (/api/v1/...) work in dev, matching the production same-origin default ("apiBase": ""). One nuance: the dashboard’s dev server serves a config.json whose apiBase points directly at the API, so the long-lived SSE + SignalR streams bypass the proxy — otherwise they pin connections to localhost:5174 and HTTP/1.1’s ~6-per-host cap can starve lazy route-chunk loads.
Common issues
- CORS errors in dev — your proxy isn’t picking up the right backend URL. Set
VITE_API_BASE_URLwhen startingnpm run dev, or fix the backend’s CORS config if you’re hitting it directly. - SignalR fails to connect after deploy — your reverse proxy isn’t forwarding WebSocket upgrade headers. Add
proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";to Nginx. - App boots to an error about
/config.json— the runtime config file is missing or unreachable. In containers, check theFSH_*env vars (the entrypoint refuses to start withoutFSH_API_URL); on static hosts, make sureconfig.jsondeployed next toindex.html. - HMR not updating in Aspire —
dotnet watchdoesn’t always proxy WebSocket upgrades for Vite. Runnpm run devseparately in that case.
Related
- Admin console — the operator app.
- Tenant dashboard — the end-user app.
- Frontend architecture — shared patterns.
- Deployment — production wiring across services.