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 http://localhost:5000
- 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:5173This requires the backend to be running separately (either via Aspire, docker-compose, or dotnet run against the API project).
The dev server reads clients/{app}/.env.development for the backend URL:
VITE_API_BASE_URL=http://localhost:5000The npm scripts
{ "scripts": { "dev": "vite", // dev server with HMR "build": "tsc -b && vite build", // typecheck + production bundle "preview": "vite preview", // serve the production bundle locally "lint": "eslint .", "test": "vitest" }}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 (env-var-driven)
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: inject a window.__ENV__ script at container startup via envsubst. The frontend reads from window.__ENV__ at boot.
The container pattern
# clients/admin/Dockerfile (simplified)FROM node:20-alpine AS buildWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
FROM nginx:1.27-alpineCOPY --from=build /app/dist /usr/share/nginx/htmlCOPY nginx.conf /etc/nginx/conf.d/default.confCOPY env.template.js /usr/share/nginx/html/env.template.jsCOPY entrypoint.sh /entrypoint.shRUN chmod +x /entrypoint.shENTRYPOINT ["/entrypoint.sh"]#!/bin/shenvsubst < /usr/share/nginx/html/env.template.js > /usr/share/nginx/html/env.jsexec nginx -g 'daemon off;'// env.template.js — substituted at container startwindow.__ENV__ = { API_BASE_URL: '${API_BASE_URL}', SIGNALR_HUB_URL: '${SIGNALR_HUB_URL}',};<script src="/env.js"></script>At deploy time, set the env vars on the container:
docker run -e API_BASE_URL=https://api.example.com fsh-adminThe same image runs everywhere; only env vars differ.
Reading runtime config in code
// clients/{app}/src/env.tsdeclare global { interface Window { __ENV__?: { API_BASE_URL?: string; SIGNALR_HUB_URL?: string }; }}
export const env = { API_BASE_URL: window.__ENV__?.API_BASE_URL ?? import.meta.env.VITE_API_BASE_URL ?? '/api', SIGNALR_HUB_URL: window.__ENV__?.SIGNALR_HUB_URL ?? import.meta.env.VITE_SIGNALR_HUB_URL ?? '/realtime/hub',};window.__ENV__ (runtime) takes precedence; import.meta.env (build-time) is the fallback; a default URL is the last resort.
Nginx configuration for SPAs
nginx.conf (simplified — the kit’s actual file is more detailed):
server { listen 80; server_name _; root /usr/share/nginx/html; index index.html;
# SPA routing — every non-asset path falls through to index.html location / { try_files $uri $uri/ /index.html; }
# Long-cache static assets (their filenames carry hashes) location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; }
# Never cache index.html so runtime config updates immediately location = /index.html { add_header Cache-Control "no-cache, no-store, must-revalidate"; }
# gzip + brotli compression gzip on; gzip_types text/css application/javascript application/json image/svg+xml;}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 - Environment variables:
VITE_API_BASE_URL=https://api.example.com(build-time)
For runtime config, use the platform’s edge config or rewrite env.js from an edge function.
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: short on
index.html, long on/assets/*.
Cloudflare Pages
Connect the repo, configure build command + output directory. Cloudflare’s Workers can inject runtime env via Pages Functions if needed.
Testing
The kit ships Vitest configuration. Run:
npm test # all testsnpm test -- --watch # watch modeTests live alongside source as *.test.ts(x). The setup is minimal — the kit doesn’t ship a full Storybook / Chromatic visual-regression stack.
Linting + formatting
ESLint + Prettier are wired:
npm run lint # reportnpm run lint -- --fix # auto-fixThe kit’s ESLint config sits in clients/{app}/eslint.config.js (flat config). It extends the standard React + TypeScript + a11y rule sets.
When the dev server proxies API calls
Vite’s dev server can proxy API calls to the backend so you don’t deal with CORS during development:
// clients/{app}/vite.config.tsexport default defineConfig({ server: { proxy: { '/api': { target: env.VITE_API_BASE_URL, changeOrigin: true }, '/realtime': { target: env.VITE_API_BASE_URL, changeOrigin: true, ws: true }, }, },});With this in place, you can use relative URLs (/api/v1/...) in code and the dev server forwards them. Production builds embed the full URL via env.API_BASE_URL.
Common issues
- CORS errors in dev — your
vite.config.tsproxy isn’t picking up the right backend URL, or you’re using absolute URLs in client code. Switch to relative URLs + verify the proxy config. - 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. - “Refused to load script” CSP violations — your
Content-Security-Policyscript-srcdoesn’t include the inlineenv.jsscript. Either widen the policy, or use a hash, or move runtime config to a non-inline mechanism. - 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.