Azure Static Web Apps
Key Points
- Static-content hosting + managed API + auth + custom-domain SSL — all in one bundle. The "Vercel for Azure" niche.
- Generous free tier: 100 GB bandwidth/month, 2 custom domains, 3 staging environments, free TLS — for hobby and small prod.
- Build pipeline: GitHub Actions or Azure DevOps. Per-PR preview environments at unique URLs — ship-and-review.
- Frameworks: Oryx auto-detects React, Vue, Angular, Svelte, Astro, Next.js (static export), Nuxt, Hugo, Jekyll, MkDocs, etc. Or
skip_app_build: trueif you prebuild. - App / API split:
app_location(frontend),api_location(managed Azure Functions backend),output_location(built artifact dir). staticwebapp.config.json: routes, fallback, MIME, headers, redirects, role-based authorization.- Built-in auth providers: Entra ID, GitHub, Twitter/X, Google, Facebook, custom OIDC. Zero token plumbing.
- Linked APIs: bring-your-own Functions or Container Apps backend on Standard tier.
- Custom domains: free managed certificate via DigiCert.
Concepts (deep dive)
What Static Web Apps is
SWA is frontend hosting bundled with the few backend services frontends usually need — a managed Functions API, an auth gateway with OIDC providers wired in, a global CDN, free TLS for custom domains, and per-PR preview environments — all stitched together so the frontend and the API are served from the same origin (no CORS) and the auth principal flows automatically. It's the "deploy a React/Vue/Svelte app with a small API" niche that used to require gluing CDN + Functions + Front Door + App Service Auth together by hand.
GitHub / Azure DevOps
─────────────────────
push / PR ──▶ build action
│
▼
┌────────────────────────────────────────────────────────┐
│ Azure Static Web Apps (global CDN) │
│ │
│ / → static assets (your built SPA) │
│ /api/* → managed Azure Functions │
│ /.auth/* → identity provider flows (AAD, GH, …) │
│ x-ms-client-principal header injected into API │
│ │
│ staticwebapp.config.json gates routes by role │
└────────────────────────────────────────────────────────┘
▲
one origin, no CORS,
free TLS, custom domain
The trade-off: the bundled API is Functions Consumption (cold starts, memory caps); the backend is for frontend support, not heavy compute. Outgrow it → Standard tier + a Linked API.
What you get vs what you don't
| Have | Don't have |
|---|---|
| Static asset CDN | Long-running compute |
| Managed Functions API | Background workers / cron at scale |
| Built-in auth | Custom auth flows (until BYO OIDC) |
| Per-PR preview env | Slot swap in App Service sense |
| Free TLS + custom domain | Region pinning per app (global by default) |
| Role-based authz via config | Fine-grained per-route claims (limited) |
Pricing tiers
| Tier | Cost | Limits |
|---|---|---|
| Free | $0 | 100 GB egress/mo, 2 custom domains, 0.5 GB API total, 3 staging envs |
| Standard | ~$9/mo + bandwidth | 100 GB included, more domains, more envs, custom auth, BYO Functions, SLA |
| Dedicated | enterprise | Reserved capacity, App Service plan |
For most internal tools, docs sites, and MVPs: Free is enough. Move to Standard when you need BYO API or custom auth.
Build pipeline
# .github/workflows/azure-static-web-apps.yml
- uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: "upload"
app_location: "app" # frontend source
api_location: "api" # Functions source (or empty)
output_location: "dist" # build output relative to app_location
Oryx (the build orchestrator) auto-detects the framework and runs npm install && npm run build (or equivalent). To skip auto-build:
This guide site itself uses skip_app_build: true because MkDocs builds upstream into _site/.
Per-PR preview environments
Open a PR → SWA builds the branch → deploys to a unique URL like nice-bay-0a1b2c3d-pr-42.azurestaticapps.net. Reviewers can interact with the live PR build before merge. PR closes → environment auto-cleaned.
main branch ──▶ production
PR #42 ──▶ staging-pr-42
PR #43 ──▶ staging-pr-43
PR closed ──▶ environment removed
App / API split
my-app/
├── app/ # app_location
│ ├── src/
│ ├── package.json
│ └── dist/ # output_location (relative)
└── api/ # api_location (Azure Functions)
├── orders/
│ ├── function.json
│ └── index.js
└── host.json
The frontend talks to the API via same-origin /api/* routes — SWA wires them together. No CORS pain.
// api/orders/Function.cs (.NET isolated)
public class GetOrders
{
[Function("orders")]
public HttpResponseData Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req)
{
var resp = req.CreateResponse(HttpStatusCode.OK);
resp.WriteAsJsonAsync(new[] { new { id = 1, total = 100 } });
return resp;
}
}
staticwebapp.config.json — the routing brain
Lives at the root of output_location. Powers routes, headers, fallback, auth.
{
"routes": [
{ "route": "/admin/*", "allowedRoles": ["admin"] },
{ "route": "/api/orders", "methods": ["GET"], "allowedRoles": ["authenticated"] },
{ "route": "/legacy", "redirect": "/new", "statusCode": 301 }
],
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["/images/*.{png,jpg}", "/css/*", "/api/*"]
},
"responseOverrides": {
"404": { "rewrite": "/404.html" }
},
"globalHeaders": {
"Cache-Control": "no-cache",
"Content-Security-Policy": "default-src 'self'"
},
"mimeTypes": { ".webmanifest": "application/manifest+json" }
}
Key concepts: - navigationFallback: SPA hash routing — anything not matching a file falls back to index.html. Exclude /api/* and asset paths. - allowedRoles: gates routes by role from the auth provider. - globalHeaders: applied to all responses — set CSP / security headers here.
Built-in auth — zero token plumbing
Identity provider lookups on /.auth/login/<provider>:
/.auth/login/aad Entra ID
/.auth/login/github GitHub
/.auth/login/google Google
/.auth/login/twitter Twitter / X
/.auth/login/facebook Facebook
/.auth/me current user (JSON)
/.auth/logout sign out
The user object is injected into requests automatically:
const me = await fetch("/.auth/me").then(r => r.json());
// { clientPrincipal: { userId, userDetails, identityProvider, userRoles: [...] } }
In your Functions backend, the principal arrives in the x-ms-client-principal header (base64 JSON):
var principalJson = Encoding.UTF8.GetString(
Convert.FromBase64String(req.Headers["x-ms-client-principal"]));
var principal = JsonSerializer.Deserialize<ClientPrincipal>(principalJson);
Role-based authorization
{
"auth": {
"rolesSource": "/api/GetRoles", // optional: function returns roles
"identityProviders": {
"azureActiveDirectory": { /* tenant config */ }
}
},
"routes": [
{ "route": "/admin/*", "allowedRoles": ["admin"] }
]
}
Default roles: anonymous, authenticated. Custom roles assigned via portal role invitations or computed dynamically by an /api/GetRoles function.
Linked APIs (Standard tier)
Instead of the bundled managed Functions:
Then link an existing Azure Functions or Container Apps backend in the portal. Same-origin /api/* is preserved; auth principal still flows. Use when: - API needs more than 250 MB / longer than the bundled limits. - API is shared with other consumers. - API needs Premium plan / VNet.
Custom domains
1. Add CNAME in DNS → <app>.azurestaticapps.net
2. Validate in portal
3. SWA provisions free DigiCert certificate
4. Auto-renew (90-day cycle)
Free TLS for unlimited custom domains on any tier.
Comparison
| Service | Sweet spot | Why over SWA |
|---|---|---|
| App Service | Full backend app, slots, scale-out, language-agnostic | Long-running, stateful, complex backend |
| Azure Functions standalone | API-only | No frontend hosting bundled |
| GitHub Pages | Static-only docs | No API, no auth |
| Vercel / Netlify | Frontend-first, sleek DX | More framework integrations; cross-cloud |
| Azure Container Apps | Containerized backend | Custom runtime, scale-to-zero containers |
How it works under the hood
┌──────────────── Azure Static Web Apps ────────────────┐
│ │
│ Global CDN edge ──────▶ static asset cache │
│ │ │
│ │ /api/* routed │
│ ▼ │
│ Auth gateway (validates principal) │
│ │ │
│ ▼ │
│ Managed Azure Functions (app's api/ folder) │
│ OR Linked API (BYO Functions / Container Apps) │
│ │
│ /.auth/* → identity provider OAuth flow │
└───────────────────────────────────────────────────────┘
▲ ▲
│ HTTPS (custom domain) │ Build artifacts
│ │ uploaded by GH Actions / ADO
[Browsers] [Build pipeline]
Build pipeline produces a tarball; deployed to global CDN; routing config from staticwebapp.config.json enforced at the edge; /api/* routes proxied to the bundled Functions runtime in the same data center.
Code: correct vs wrong
❌ Wrong: SPA fallback eating asset routes
Now /images/logo.png returns index.html if the asset is missing.
✅ Correct: explicit excludes
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": ["/images/*", "/css/*", "/js/*", "/api/*", "*.{png,jpg,svg,webp}"]
}
}
❌ Wrong: secrets in api/local.settings.json committed
✅ Correct: configure in portal / Key Vault references
❌ Wrong: gating sensitive route only on the frontend
Any user can hit the API endpoint anyway.
✅ Correct: gate the route AND the API
{ "route": "/admin/*", "allowedRoles": ["admin"] },
{ "route": "/api/admin/*", "allowedRoles": ["admin"] }
❌ Wrong: cross-origin call to a separate API
✅ Correct: same-origin via SWA-linked API
Design patterns for this topic
Pattern 1 — "SPA + bundled Functions API"
- Intent: SPA frontend + small managed API; one repo; one deploy.
- How:
app/,api/,staticwebapp.config.json.
Pattern 2 — "Per-PR preview env for review"
- Intent: review live builds before merge.
- How: GitHub Actions + SWA auto-creates env per PR.
Pattern 3 — "Linked API on Standard tier"
- Intent: outgrow bundled Functions; reuse existing API.
- How: Standard tier; link Functions / Container Apps; same-origin preserved.
Pattern 4 — "Skip-app-build for prebuilt sites"
- Intent: custom static generator (MkDocs, Hugo, Eleventy).
- How:
skip_app_build: true, pointapp_locationat built dir.
Pattern 5 — "Built-in auth + role-based routes"
- Intent: zero-plumbing auth.
- How: identity provider config +
allowedRolesper route.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Free tier | Generous; real prod for small apps | API memory limited |
| Built-in auth | No token plumbing | Limited customization (until BYO OIDC) |
| Per-PR env | Live review URLs | Each env counts against tier limit |
| Bundled API | Same-origin + auth | Functions consumption only; cold starts |
| Linked API (Standard) | Premium plan, VNet | Costs more; more moving parts |
| Custom domain TLS | Free | DigiCert only |
| Routing config | Powerful | YAML-ish JSON; no full server logic |
When to use / when to avoid
- Use for: marketing sites, docs portals, demo apps, MVPs, internal tools with auth, JAMstack patterns.
- Use when you want per-PR preview URLs out of the box.
- Use when same-origin API + zero-CORS is valuable.
- Avoid for: heavy backend logic, long-running compute, regulated workloads in unsupported regions (SWA is global; data residency is best-effort).
- Avoid when you need fine-grained scaling controls or VNet on the API tier (use App Service / Container Apps).
- Avoid for stateful apps (use App Service or Container Apps).
Interview Q&A
Q1. What does Azure Static Web Apps bundle? Static hosting + managed Azure Functions API + built-in auth + custom-domain TLS + per-PR preview environments + a routing config file.
Q2. Free vs Standard tier? Free covers most hobby and small prod. Standard adds custom auth, BYO Functions/Container Apps, more environments, SLA.
Q3. What's staticwebapp.config.json? Routing config: routes (with role guards), navigation fallback, headers, redirects, MIME types, auth providers.
Q4. How do per-PR previews work? Build action deploys each PR to a unique URL. Auto-cleanup on PR close. Counted against env limit per tier.
Q5. App vs API location? app_location = frontend source. api_location = Functions source. output_location = built artifact dir under app_location.
Q6. How does built-in auth work? Identity provider config + /.auth/* endpoints. Principal injected as x-ms-client-principal header to API; available via /.auth/me to frontend.
Q7. How do I gate routes by role? allowedRoles in staticwebapp.config.json. Roles = anonymous, authenticated, plus invited custom roles or computed via /api/GetRoles.
Q8. When use a Linked API? When bundled Functions limits are too small, the API is shared with other consumers, or you need Premium plan / VNet / Container Apps.
Q9. SWA vs App Service? SWA for static frontend + small API. App Service for full backend, long-running compute, scale-out plans, slots.
Q10. Custom domain TLS cost? Free, managed by SWA via DigiCert. Auto-renewed.
Q11. Can I deploy from Azure DevOps? Yes — there's an ADO task equivalent to the GitHub Action. Same app_location / api_location / output_location parameters.
Q12. What's skip_app_build? Skip Oryx auto-build; use prebuilt artifacts in app_location. For static generators that build upstream (MkDocs, Hugo, Eleventy).
Gotchas / common mistakes
- ⚠️ Navigation fallback eating assets — exclude asset paths.
- ⚠️ Frontend-only role check — gate the API too.
- ⚠️ Secrets in
api/local.settings.jsoncommitted — use portal config. - ⚠️ Cross-origin API call — lose auth principal flow; use bundled or linked.
- ⚠️ CORS misconfig on linked API — same-origin via SWA front-door avoids CORS entirely.
- ⚠️ Per-PR env quota exhaustion — Free tier = 3 staging slots.
- ⚠️ Region assumptions — SWA is global CDN; not pinned to a region.
- ⚠️ Functions cold start surprise — bundled API uses Consumption-model Functions.
- ⚠️ Ignoring
globalHeaders— CSP/HSTS go here, not in HTML.