Frontend (Azure Static Web Apps)¶
The frontend is deployed to Azure Static Web Apps via GitHub Actions. Separate instances run for ACC and PROD environments.
Architecture¶
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ GitHub Repository (ronl-business-api) โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ packages/frontend/ โ โ
โ โ โโโ src/ โ โ
โ โ โโโ public/ โ โ
โ โ โ โโโ tenants.json โ โ
โ โ โ โโโ staticwebapp.config.json โ โ โ SPA routing config
โ โ โโโ vite.config.ts โ โ
โ โ โโโ package.json โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โ Push to 'acc' branch โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ .github/workflows/ โ โ
โ โ โโโ azure-frontend-acc.yml โ โ
โ โ โโโ azure-frontend-prod.yml โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โ GitHub Actions
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Azure Static Web Apps โ
โ โ
โ ACC: acc.mijn.open-regels.nl โ
โ PROD: mijn.open-regels.nl โ
โ โ
โ โ
Global CDN โ
โ โ
Automatic SSL โ
โ โ
Custom domains โ
โ โ
SPA fallback routing โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
GitHub Actions Workflows¶
ACC Workflow¶
File: .github/workflows/azure-frontend-acc.yml โ abridged: comments
removed, and the changes and close_pull_request_job jobs shortened to their
outline.
name: Deploy Frontend to Azure ACC
on:
push:
branches:
- acc
paths:
- 'packages/frontend/**'
- 'packages/shared/**'
- 'packages/pa-cockpit/**'
- '.github/workflows/azure-frontend-acc.yml'
- 'package-lock.json'
- 'package.json'
- '.nvmrc'
pull_request:
types: [opened, synchronize, reopened, closed, labeled]
branches:
- acc
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
changes: # outputs `relevant` (build?) and `preview` (deploy a preview?)
...
build_and_deploy_job:
needs: changes
permissions:
contents: read
pull-requests: write
if: ${{ !cancelled() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.action != 'closed')) && (needs.changes.result != 'success' || needs.changes.outputs.relevant == 'true') }}
runs-on: ubuntu-24.04
name: Build and Deploy ACC Frontend
environment:
name: acc
url: https://acc.mijn.open-regels.nl
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: true
lfs: false
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
- name: Install dependencies
run: npm ci
- name: Build shared package
run: npm run build --workspace=@ronl/shared
- name: Run linter
working-directory: packages/frontend
run: npm run lint
- name: Unit tests (pa-cockpit)
working-directory: packages/pa-cockpit
run: npm test
- name: Unit tests
working-directory: packages/frontend
run: npm test
- name: Performance budget
working-directory: packages/frontend
run: npm run test:perf
- name: Build frontend for ACC
working-directory: packages/frontend
env:
VITE_BUILD_SHA: ${{ github.sha }}
VITE_BUILD_RUN: ${{ github.run_number }}
run: |
npm run build:acc
echo "Build output:"
ls -la dist/
test -f dist/index.html || (echo "ERROR: index.html not found!" && exit 1)
node scripts/check-og.mjs acceptance
echo "โ
Build completed successfully"
- name: Deploy to Azure Static Web Apps
if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.preview == 'true' }}
id: builddeploy
uses: Azure/static-web-apps-deploy@4d27395796ac319302594769cfe812bd207490b1 # v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_ACC }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: 'upload'
app_location: '/packages/frontend/dist'
api_location: ''
output_location: ''
skip_app_build: true
- name: Wait for deployment
if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.preview == 'true' }}
run: sleep 15
- name: Verify deployment
if: ${{ github.event_name != 'pull_request' || needs.changes.outputs.preview == 'true' }}
run: |
response=$(curl -s -o /dev/null -w "%{http_code}" https://acc.mijn.open-regels.nl)
if [ "$response" = "200" ]; then
echo "โ
Frontend is accessible!"
else
echo "โ ๏ธ Frontend returned HTTP $response"
fi
close_pull_request_job: # tears the preview down when the pull request closes
...
Triggers:
- A push to
accthat changespackages/frontend/**,packages/shared/**,packages/pa-cockpit/**, the rootpackage-lock.jsonorpackage.json,.nvmrcor the workflow file itself. The root lockfile and manifest are there because every workspace resolves through them: a lockfile-only change moves this app's dependencies too. - Every pull request to
acc, with no path filter on the trigger: thechangesjob applies the same paths, so the required check Build and Deploy ACC Frontend reports on every pull request. A pull request is linted, tested and built; it is deployed to a preview environment only when it carries thepreviewlabel and changed more than a manifest โ see Pull-request previews. workflow_dispatch.
The API, Keycloak and LDE URLs are not injected by the workflow: npm run
build:acc runs tsc && vite build --mode acceptance, and Vite reads them from
the committed packages/frontend/.env.acceptance โ see Environment
Files.
PROD Workflow¶
File: .github/workflows/azure-frontend-prod.yml
Same build structure as ACC, but it is not triggered by a branch at all:
- Triggers:
workflow_callandworkflow_dispatchonly. A push tomainstartspromote-to-production.yml, which calls this workflow once the backend deploy has succeeded or been skipped โ see How a promotion reaches production. - Secrets:
AZURE_STATIC_WEB_APPS_API_TOKEN_PROD, passed explicitly by the promotion (neversecrets: inherit), plusGITHUB_TOKEN. - Build provenance:
VITE_BUILD_SHAandVITE_BUILD_RUNare injected on the build step, which is what the running application prints asbuild <sha> ยท #<run>. A called workflow inherits the caller's run number, so that#is the promotion's run number, not a per-app one. - No approval gate. The
productionenvironment carries no protection rule; nothing pauses waiting for a reviewer.
SPA Routing Configuration¶
Critical for React Router: Azure Static Web Apps needs staticwebapp.config.json to handle client-side routing.
File: packages/frontend/public/staticwebapp.config.json
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["/assets/*", "/*.{css,scss,js,png,gif,ico,jpg,svg}"]
},
"routes": [
{
"route": "/*",
"allowedRoles": ["anonymous"]
}
],
"responseOverrides": {
"404": {
"rewrite": "/index.html",
"statusCode": 200
}
},
"mimeTypes": {
".json": "application/json",
".js": "text/javascript",
".css": "text/css"
},
"globalHeaders": {
"Cache-Control": "no-cache, no-store, must-revalidate"
}
}
This is the source file. The build adds one rewrite per single-board tenant
ahead of these routes โ { "route": "/amsterdam", "rewrite": "/amsterdam/index.html" }
and likewise for heusden, toeslagen and unive โ so the shipped
dist/staticwebapp.config.json serves each tenant's own page; see Step 2.
What it does:
- โ
All app routes (
/,/auth,/dashboard/...) โ served byindex.html - โ
A tenant path (
/amsterdam,/heusden,/toeslagen,/unive) โ served by that tenant's<id>/index.html, with its own link preview - โ React Router handles routing client-side
- โ Static assets excluded from fallback
- โ Proper cache headers
- โ Correct MIME types
Without this file: Routes like /auth and /dashboard return 404 errors after Keycloak redirect.
Build and Deployment Steps¶
Step 1 โ Trigger¶
Push changes to acc branch:
cd ~/Development/ronl-business-api
# Make frontend changes
nano packages/frontend/src/pages/Dashboard.tsx
# Commit and push
git add packages/frontend/
git commit -m "feat: update dashboard styling"
git push origin acc
Step 2 โ GitHub Actions Build¶
The workflow automatically:
- Checks out code
- Sets up Node.js from
.nvmrc(22.23.3) - Installs dependencies with
npm ciand builds@ronl/shared - Lints, then runs the PA cockpit tests, the frontend tests and the performance budget
- Builds with
npm run build:acc(orbuild:prod), reading the committed.env.acceptance(or.env.production), intopackages/frontend/dist/. After Vite's own build,vite-plugin-tenant-pages.tscopiesdist/index.htmlonce per single-board tenant intodist/<id>/index.htmlwith that tenant's title, description, canonical URL and Open Graph tags (from itsshareentry intenants.json, and the cardog-image-<id>-<acc|prod>.png), and adds one rewrite per tenant to the shippedstaticwebapp.config.json - Runs
node scripts/check-og.mjs acceptance(orproduction) against the builtdist/
Build artifacts:
dist/
โโโ index.html # Entry point, link-preview tags filled per mode
โโโ assets/
โ โโโ index-[hash].js # Main bundle
โ โโโ index-[hash].css # Styles
โ โโโ [other assets]
โโโ og-image-acc.png # Link-preview images (copied from public/)
โโโ og-image-prod.png
โโโ og-image-<id>-{acc,prod}.png # One pair per tenant page: amsterdam, heusden, toeslagen, unive
โโโ amsterdam/index.html # A tenant page per single-board tenant (also heusden/, toeslagen/, unive/)
โโโ tenants/ # Tenant logos (copied from public/)
โโโ tenants.json # Municipality config (copied from public/)
โโโ staticwebapp.config.json # SPA routing, plus one rewrite per tenant page
check-og.mjs fails the build step unless dist/index.html carries this
environment's link preview: og:url, og:image, og:title (prefixed [ACC]
on acceptance), robots (noindex, nofollow on acceptance, index, follow on
production) and the canonical link, with no %VITE_โฆ% placeholder left
unfilled and the named image present in dist/. src/indexHtml.test.ts proves
the template and the .env files agree; this step proves the file actually
shipped is the one for its environment, because an acceptance card that
reaches Teams or LinkedIn is cached there for days.
The same check covers every tenant page: dist/<id>/index.html must exist with
og:url and canonical <site>/<id>, the card og-image-<id>-<acc|prod>.png
(present in dist/), the environment's title prefix and robots value, and a
rewrite for /<id> in the shipped staticwebapp.config.json. It also fails
when two routes there differ only by a trailing slash: Static Web Apps treats
them as one route and refuses the whole configuration at upload, after a check
that looked only at the HTML would have passed.
Step 3 โ Azure Deployment¶
The Azure/static-web-apps-deploy@v1 action:
- Authenticates with Azure using API token
- Uploads
dist/folder to Azure Storage - Invalidates CDN cache
- Updates routing rules from
staticwebapp.config.json - Makes new version live
Deployment time: ~2-3 minutes
Step 4 โ Verification¶
# Check deployment status
# GitHub Actions โ Workflows โ Deploy Frontend to Azure ACC
# Test URLs
curl -I https://acc.mijn.open-regels.nl
# Should return: 200 OK
curl -I https://acc.mijn.open-regels.nl/auth
# Should return: 200 OK (not 404!)
# Test in browser
# Visit: https://acc.mijn.open-regels.nl
# Click "Inloggen met DigiD"
# After Keycloak auth, /auth route should work
# Dashboard should load at /dashboard
Environment Files¶
The build-time configuration is committed, not stored as GitHub Secrets.
Each build script names a Vite mode โ build:acc is vite build --mode
acceptance, build:prod is vite build --mode production โ and Vite reads
packages/frontend/.env.<mode>. None of these values is secret: they end up in
the public bundle and in index.html.
| Variable | .env.development |
.env.acceptance |
.env.production |
|---|---|---|---|
VITE_API_URL |
http://localhost:3002/v1 |
https://acc.api.open-regels.nl/v1 |
https://api.open-regels.nl/v1 |
VITE_KEYCLOAK_URL |
http://localhost:8080 |
https://acc.keycloak.open-regels.nl |
https://keycloak.open-regels.nl |
VITE_LDE_API_URL |
http://localhost:3001/v1 |
https://acc.backend.linkeddata.open-regels.nl/v1 |
https://backend.linkeddata.open-regels.nl/v1 |
VITE_SITE_URL |
http://localhost:5173 |
https://acc.mijn.open-regels.nl |
https://mijn.open-regels.nl |
VITE_OG_IMAGE |
og-image-acc.png |
og-image-acc.png |
og-image-prod.png |
VITE_OG_TITLE_PREFIX |
"[DEV] " |
"[ACC] " |
"" |
VITE_ROBOTS |
noindex, nofollow |
noindex, nofollow |
index, follow |
The three VITE_PA_*_MOCK flags are false in all three files. The four
link-preview variables fill the %VITE_โฆ% placeholders in index.html โ the
Open Graph and Twitter tags, the robots meta tag and the canonical link โ and
VITE_OG_TITLE_PREFIX is quoted so its trailing space survives.
The workflows add only two variables of their own, on the build step:
VITE_BUILD_SHA (github.sha) and VITE_BUILD_RUN (github.run_number). Vite
merges VITE_-prefixed process environment over the mode file, so they reach
import.meta.env without touching it; without them the app prints local
build.
Secrets the workflows use¶
| Secret | Used by |
|---|---|
AZURE_STATIC_WEB_APPS_API_TOKEN_ACC |
azure-frontend-acc.yml โ upload, and closing a preview |
AZURE_STATIC_WEB_APPS_API_TOKEN_PROD |
azure-frontend-prod.yml, passed by name from promote-to-production.yml |
GITHUB_TOKEN |
repo_token on the deploy step |
Adding or updating a deployment token¶
Store a token with scripts/set-secret.sh, never by piping it straight into
gh secret set, which stores a trailing newline โ see Storing a token without
breaking the deploy.
To change a URL, edit the .env.<mode> file in a pull request; it takes effect
on the next build.
Azure Static Web App Configuration¶
Resource Details¶
ACC:
- Name:
ronl-frontend-acc - Resource Group:
rg-ronl-acc - Region: West Europe
- Custom Domain:
acc.mijn.open-regels.nl - Pricing: Free tier (sufficient for this application)
PROD:
- Name:
ronl-frontend-prod - Resource Group:
rg-ronl-prod - Region: West Europe
- Custom Domain:
mijn.open-regels.nl - Pricing: Standard tier (for production SLA)
Deployment Token¶
Retrieve token from Azure Portal:
- Navigate to Static Web App resource
- Left menu โ Deployment tokens
- Copy Deployment token
- Store it as
AZURE_STATIC_WEB_APPS_API_TOKEN_ACC(or_PROD) withscripts/set-secret.sh
Token permissions:
- Upload build artifacts
- Update routing configuration
- Invalidate CDN cache
Custom Domain Setup¶
ACC domain (acc.mijn.open-regels.nl):
- Azure Portal โ Static Web App โ Custom domains
- Click Add
- Enter:
acc.mijn.open-regels.nl - Choose: Other DNS
- Add DNS records:
- Verify and add
SSL certificate: Automatically provisioned by Azure (Let's Encrypt)
Post-Deployment Verification¶
Automated checks in the workflow¶
Both frontend workflows gate the deploy on tests that run before the build, each as its own step, so a failure stops the run before anything is uploaded:
| Step | Command | What it covers |
|---|---|---|
| Run linter | npm run lint (in packages/frontend) |
ESLint |
| Unit tests (pa-cockpit) | npm test (in packages/pa-cockpit) |
The PA cockpit library the frontend consumes, ahead of the frontend's own suite |
| Unit tests | npm test (in packages/frontend) |
vitest run --coverage |
| Performance budget | npm run test:perf |
The *.perf.test.ts wall-clock budgets, run alone under vitest.perf.config.ts |
After the build, still inside the build step, node scripts/check-og.mjs
acceptance (or production) checks the built dist/index.html โ see Step 2 โ
GitHub Actions Build.
After the deploy, the workflow waits 15 seconds and requests the root URL.
That check reports but does not fail: a status other than 200 prints a
warning and the job still succeeds. It requests / only, not the SPA routes or
static files.
The frontend's Playwright suite (packages/frontend/e2e/, npm run test:e2e)
does not run in either workflow; it needs a running backend, Keycloak and
engine. The only end-to-end suite in CI is the PA demo's, in
azure-pa-demo-acc.yml โ see CI/CD โ What each pipeline
runs.
Manual Testing Checklist¶
- Landing page loads (https://acc.mijn.open-regels.nl)
- Three IDP buttons visible and styled correctly
- Changelog panel opens/closes
- DigiD button โ redirects to Keycloak
- After auth โ
/authroute works (no 404) - Dashboard loads at
/dashboard - Municipality theme applied correctly
- Calculator form submits
- Mobile responsive (test on phone)
- No console errors in browser DevTools
Manual Deployment¶
If GitHub Actions fails or for emergency hotfix:
Step 1 โ Build locally¶
cd ~/Development/ronl-business-api/packages/frontend
# Set environment variables
export VITE_KEYCLOAK_URL=https://acc.keycloak.open-regels.nl
export VITE_API_URL=https://acc.api.open-regels.nl/v1
# Build
npm run build
# Output: dist/ directory
ls -la dist/
Step 2 โ Deploy via Azure CLI¶
# Install Azure CLI (if not installed)
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
# Login to Azure
az login
# Deploy to ACC
az staticwebapp deploy \
--name ronl-frontend-acc \
--resource-group rg-ronl-acc \
--source ./dist \
--no-use-keychain
# Wait for deployment (~2-3 minutes)
Step 3 โ Verify¶
# Test deployment
curl -I https://acc.mijn.open-regels.nl
# Should return: 200 OK
# Open in browser
# Visit: https://acc.mijn.open-regels.nl
Keycloak Redirect URIs¶
Critical: Keycloak client must allow redirects from deployed URLs.
ACC Configuration¶
Keycloak Admin Console:
- Realm:
ronl - Clients โ
ronl-business-api - Valid Redirect URIs:
https://acc.mijn.open-regels.nl/* - Valid Post Logout Redirect URIs:
+(inherits from redirect URIs) - Web Origins:
https://acc.mijn.open-regels.nl
PROD Configuration¶
Same as ACC but with:
- Valid Redirect URIs:
https://mijn.open-regels.nl/* - Web Origins:
https://mijn.open-regels.nl
Rollback Procedure¶
If deployment causes issues:
Option 1 โ Revert via GitHub¶
# Find last working commit
git log --oneline packages/frontend/
# Revert to that commit
git revert <commit-hash>
# Push to trigger new deployment
git push origin acc
Option 2 โ Rollback in Azure Portal¶
- Azure Portal โ Static Web App
- Left menu โ Environments
- Select previous deployment
- Click Promote
Troubleshooting¶
404 on routes after deployment¶
Symptoms: Landing page works, but /auth and /dashboard return 404.
Cause: staticwebapp.config.json missing or not deployed.
Solution:
# Verify file exists in source
ls -la packages/frontend/public/staticwebapp.config.json
# File must be in public/ to be copied to dist/ during build
# If missing, create it:
cat > packages/frontend/public/staticwebapp.config.json << 'EOF'
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["/assets/*", "/*.js", "/*.css", "/*.json"]
}
}
EOF
# Commit and redeploy
git add packages/frontend/public/staticwebapp.config.json
git commit -m "fix: add SPA routing configuration"
git push origin acc
Environment variables not applied¶
Symptoms: App loads but can't connect to Keycloak or API.
Cause: The bundle was built in the wrong Vite mode, or the mode file holds
the wrong URL. The values come from the committed .env.<mode> files, not from
GitHub Secrets.
Solution:
# Which mode does the build script use?
grep '"build:' packages/frontend/package.json
# build:acc โ vite build --mode acceptance โ .env.acceptance
# build:prod โ vite build --mode production โ .env.production
# What does that mode file say?
cat packages/frontend/.env.acceptance
# Fix the file in a pull request; the next build picks it up
CORS errors in browser console¶
Symptoms: Browser shows CORS policy errors when calling API.
Cause: Keycloak or API not configured for frontend origin.
Solution:
# Keycloak: Web Origins should be '+'
# This inherits all Valid Redirect URIs as allowed origins
# API: Backend must allow frontend origin in CORS config
# packages/backend/src/index.ts
app.use(cors({
origin: [
'https://acc.mijn.open-regels.nl',
'https://mijn.open-regels.nl'
],
credentials: true
}));
Deployment takes too long or fails¶
Symptoms: GitHub Actions workflow runs for 10+ minutes or fails.
Cause: Azure Static Web Apps service issues or incorrect token.
Solution:
# Check Azure service status
# Visit: https://status.azure.com/
# Verify deployment token is valid
# Azure Portal โ Static Web App โ Deployment tokens
# Regenerate if needed and update GitHub Secret
# Check workflow logs for specific error
# GitHub Actions โ Failed workflow โ View logs
# Common errors:
# - "401 Unauthorized" โ Token invalid, regenerate
# - "429 Too Many Requests" โ Wait and retry later
Build succeeds but app doesn't update¶
Symptoms: Deployment completes successfully but changes not visible.
Cause: Browser cache or CDN cache.
Solution:
# Hard refresh browser
# Chrome: Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)
# Firefox: Ctrl+F5 (Windows/Linux) or Cmd+Shift+R (Mac)
# Check deployed version
# View source: https://acc.mijn.open-regels.nl
# Look for timestamp or version comments
# CDN cache usually invalidates within 5 minutes
# Wait and try again if issue persists
Performance Optimization¶
Bundle Size¶
Current build stats (~production):
- Total bundle: ~250 KB gzipped
- index.js: ~200 KB (React, React Router, Keycloak, Axios)
- index.css: ~50 KB (Tailwind CSS)
Optimization opportunities:
- โ Vite code splitting (already enabled)
- โ Tree shaking (already enabled)
- โ ๏ธ Consider lazy loading routes for larger apps
- โ ๏ธ Consider removing unused Tailwind classes
CDN Performance¶
Azure Static Web Apps provides:
- โ Global CDN distribution
- โ Automatic gzip/brotli compression
- โ HTTP/2 support
- โ
Edge caching (configured via
staticwebapp.config.json)
Cache headers:
Currently set to no-cache for development. For production, consider:
{
"routes": [
{
"route": "/assets/*",
"headers": {
"cache-control": "public, max-age=31536000, immutable"
}
}
]
}
Related Documentation¶
- Frontend Development โ Local development setup
- Keycloak Deployment โ Redirect URI configuration
- Backend Deployment โ API CORS configuration
- Deployment Overview โ Full architecture
- CI/CD Guide โ GitHub Actions workflows
Questions or issues? See Troubleshooting or contact the DevOps team.