Skip to content
fullstackhero

Guide

Local development

Running the React frontends with Vite HMR, building for production, deploying to static hosts or behind Nginx, runtime config via /config.json.

views 0 Last updated

Both clients/admin and clients/dashboard are Vite-based React SPAs. Standard npm lifecycle, standard build output, no special tooling required.

Running locally

Terminal window
dotnet run --project src/Host/FSH.Starter.AppHost

This starts everything:

Vite HMR works through Aspire - edits to frontend files reload instantly in the browser.

Via npm (frontend-only)

Terminal window
cd clients/admin # or clients/dashboard
npm install # first time only
npm 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

Terminal window
cd clients/admin
npm run build

Output:

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:26-alpine runs npm ci && npm run build, then nginx:1.31.8-alpine (pinned) serves dist/. The entrypoint renders the runtime config before starting Nginx:

Terminal window
# clients/{app}/docker/docker-entrypoint.sh
: "${FSH_API_URL:?FSH_API_URL is required (e.g. https://api.example.com)}"
: "${FSH_DEFAULT_TENANT:=root}"
: "${FSH_INACTIVITY_IDLE_MS:=600000}" # admin; the dashboard defaults to 1200000
: "${FSH_INACTIVITY_WARNING_MS:=60000}"
# ...exits with an error unless both inactivity values are positive integers
envsubst < /etc/fsh/config.json.template \
> /usr/share/nginx/html/config.json
exec nginx -g 'daemon off;'

The template lives at /etc/fsh/config.json.template, outside the web root, so it is never served and config.json is re-rendered on every start - restarting the same container is safe.

clients/admin/docker/config.json.template
{
"apiBase": "${FSH_API_URL}",
"defaultTenant": "${FSH_DEFAULT_TENANT}",
"inactivityIdleMs": ${FSH_INACTIVITY_IDLE_MS},
"inactivityWarningMs": ${FSH_INACTIVITY_WARNING_MS},
"dashboardUrl": "${FSH_DASHBOARD_URL}"
}

At deploy time, set the env vars on the container:

Terminal window
docker run -e FSH_API_URL=https://api.example.com -e FSH_DASHBOARD_URL=https://app.example.com fsh-admin

The same image runs everywhere; only env vars differ.

VariableAppsDefaultMaps to
FSH_API_URLbothrequiredapiBase
FSH_DASHBOARD_URLadminrequireddashboardUrl
FSH_DEFAULT_TENANTbothrootdefaultTenant
FSH_INACTIVITY_IDLE_MSbothadmin 600000 (10 min), dashboard 1200000 (20 min)inactivityIdleMs
FSH_INACTIVITY_WARNING_MSboth60000 (60 s)inactivityWarningMs

The two inactivity values are bare JSON numbers in the template, so the entrypoint validates them: unset or empty keeps the default, and anything that is not a positive integer (abc, 0, -5, 1.5) stops the container at start with an error instead of serving a broken config.json. The entrypoint defaults mirror the ones in each app’s src/env.ts; change both together. The Docker Compose deployment reads FSH_ADMIN_INACTIVITY_IDLE_MS, FSH_DASHBOARD_INACTIVITY_IDLE_MS and FSH_INACTIVITY_WARNING_MS from .env, because the two apps default to different idle times.

Reading runtime config in code

// main.tsx awaits this before mounting React
await loadRuntimeConfig(); // fetch("/config.json", { cache: "no-store" })
// everywhere else
import { 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

  1. npm run build, then aws s3 sync dist/ s3://fsh-admin-bucket/
  2. CloudFront distribution in front, with a default-root-object of index.html + a custom error response that serves index.html for 404 (so SPA routing works).
  3. Set cache TTLs: no-store on config.json, short on index.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:

Terminal window
npm run test:e2e # headless run
npm run test:e2e:ui # Playwright's interactive UI mode

Tests 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 10 is wired (flat config at clients/{app}/eslint.config.js, extending the React + TypeScript + a11y rule sets). The React hooks rules are the React Compiler-aware set from eslint-plugin-react-hooks 7, so a setState called synchronously in an effect body, a ref read or written during render, or a component defined inside another component is a lint error. The a11y rules come from eslint-plugin-jsx-a11y-x, a fork of eslint-plugin-jsx-a11y with the same rules, because the original has no ESLint 10 release yet; it is registered under the jsx-a11y name, so rule ids are unchanged:

Terminal window
npm run lint # report
npm run lint -- --fix # auto-fix

The 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_URL when starting npm 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 the FSH_* env vars (the entrypoint refuses to start without FSH_API_URL); on static hosts, make sure config.json deployed next to index.html.
  • HMR not updating in Aspire - dotnet watch doesn’t always proxy WebSocket upgrades for Vite. Run npm run dev separately in that case.