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:22-alpine runs npm ci && npm run build, then nginx:alpine 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}"
envsubst < /usr/share/nginx/html/config.json.template \
> /usr/share/nginx/html/config.json
exec nginx -g 'daemon off;'
clients/admin/docker/config.json.template
{
"apiBase": "${FSH_API_URL}",
"defaultTenant": "${FSH_DEFAULT_TENANT}",
"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.

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 is wired (flat config at clients/{app}/eslint.config.js, extending the React + TypeScript + a11y rule sets):

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 Aspiredotnet watch doesn’t always proxy WebSocket upgrades for Vite. Run npm run dev separately in that case.