Skip to content
Azure

Why Azure Static Web Apps Returns 404 on Refresh (and the Fix That Does Not Break SEO)

6 min read

You deploy a React, Vue or Angular app to Azure Static Web Apps. The homepage works. Clicking through the navigation works. Then you refresh on /about, or paste a deep link into a new tab, and you get a 404.

This is not a bug, and the usual fix found in five minutes of searching creates a subtler problem that will cost you in Search Console.

Why it happens

A single-page application has one real file index.html. Routes like /about exist only in your JavaScript router, which runs after that file loads. When you navigate inside the app, the router intercepts the click and no request reaches the server.

A refresh is different. The browser asks Azure directly for /about. Azure looks for a file at that path, finds nothing, and does the correct thing: it returns 404. The router never gets a chance to run, because the document that contains it was never served.

The fix

Add a staticwebapp.config.json at the root of your build output the directory named in output_location in your workflow, or a file in public/ that your bundler copies there.

{
  "navigationFallback": {
    "rewrite": "/index.html",
    "exclude": ["/assets/*", "*.{css,js,json,txt,xml,webmanifest}", "*.{png,jpg,jpeg,svg,webp,avif,ico}", "*.{woff,woff2,ttf,otf}", "*.{mp4,webm}"]
  }
}

Any request that does not match a real file is now served index.html, the router boots, and it resolves the path itself.

The part that matters, and that most guides skip

Almost every answer online gives you the rewrite without a meaningful exclude list or with "exclude": ["/images/*"] and nothing else. That configuration says: serve index.html for literally anything not on disk.

Consider what that does to a request for a stylesheet you renamed and forgot to update. Instead of 404, the browser receives a complete HTML document with Content-Type: text/html and status 200 OK. The stylesheet silently does not apply, and nothing in your logs registers a problem.

The search consequence is worse. Crawlers requesting anything that no longer exists get HTML and a 200. Google calls this a soft 404, and the effects are cumulative:

  • Deleted pages never drop out of the index, because nothing ever tells the crawler they are gone.
  • Crawl budget is spent re-fetching URLs that resolve to the same shell.
  • Mistyped or hallucinated URLs from anywhere on the web become indexable pages.
  • Search Console fills with "Duplicate without user-selected canonical" for URLs you never created.

The exclusion list above keeps asset requests honest. Anything with a recognisable file extension returns a real 404; only extensionless navigation requests get the fallback.

Serving a real 404 page

With a fallback in place, an unknown route reaches your router, which renders your not-found component with a 200 status, because the rewrite already happened. For a genuinely missing route you want the status to match the page.

Static Web Apps lets you attach a response override:

{
  "navigationFallback": {
    "rewrite": "/index.html",
    "exclude": ["/assets/*", "*.{css,js,json,txt,xml,webmanifest}", "*.{png,jpg,jpeg,svg,webp,avif,ico}", "*.{woff,woff2,ttf,otf}"]
  },
  "responseOverrides": {
    "404": { "rewrite": "/404.html" }
  }
}

The stronger answer is to stop relying on the fallback for pages you know about. If you pre-render each route to its own index.html at build time, every real URL is a real file: Azure serves it directly with a 200, and crawlers get complete HTML without executing JavaScript. The navigation fallback then only ever catches genuine mistakes which is what it should be for.

Two adjacent problems worth fixing at the same time

Caching

Bundlers fingerprint asset filenames, so those files can be cached permanently, while HTML must not be. Set both explicitly:

{
  "routes": [
    { "route": "/assets/*", "headers": { "cache-control": "public, max-age=31536000, immutable" } },
    { "route": "/*.html",   "headers": { "cache-control": "public, max-age=0, must-revalidate" } }
  ]
}

One canonical hostname

After adding a custom domain, the generated *.azurestaticapps.net hostname stays live and serves the identical site. That is duplicate content on a domain you do not control. Add a canonical link tag on every page, and confirm your www and apex variants resolve to one host rather than both answering.

A short checklist

  1. Deep links and refreshes work on every route.
  2. A request for a file that does not exist returns 404, not 200 with HTML.
  3. A genuinely unknown route returns a 404 status, not just a 404-looking page.
  4. Fingerprinted assets are immutable; HTML revalidates.
  5. Exactly one hostname is canonical, and the rest redirect to it.
Related services

If this is the problem you have

Written by the Korame team. More about how we work.

Tell us what you are trying to build.

A short conversation is usually enough to tell you whether this is a week of work or a quarter of it and whether you should be building it at all.