Skip to content

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: true if 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:

    skip_app_build: true
    app_location: "_site"          # already-built artifacts

This guide site itself uses skip_app_build: true because MkDocs builds upstream into _site/.

Per-PR preview environments

Open a PRSWA 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.

// frontend
const orders = await fetch("/api/orders").then(r => r.json());
// 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:

api_location: ""   # disable bundled API

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

{ "navigationFallback": { "rewrite": "/index.html" } }

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

{ "Values": { "DB_CONNECTION": "Server=...;Password=..." } }

✅ Correct: configure in portal / Key Vault references

Portal → SWA → Configuration → Application settings → Key Vault reference

❌ Wrong: gating sensitive route only on the frontend

if (user.role !== "admin") return navigate("/");

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

fetch("https://my-api.azurewebsites.net/orders");   // CORS, no auth principal

✅ Correct: same-origin via SWA-linked API

fetch("/api/orders");   // principal flows automatically

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, point app_location at built dir.

Pattern 5 — "Built-in auth + role-based routes"

  • Intent: zero-plumbing auth.
  • How: identity provider config + allowedRoles per 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.json committed — 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 assumptionsSWA is global CDN; not pinned to a region.
  • ⚠️ Functions cold start surprise — bundled API uses Consumption-model Functions.
  • ⚠️ Ignoring globalHeadersCSP/HSTS go here, not in HTML.

Further reading