Firebase Hosting route 404 after deploy: 5 causes and fixes

Pages work locally but 404 on Firebase Hosting. Diagnose it in ten minutes with the exact request priority order, firebase.json snippets, and curl commands that pinpoint the cause.

Your local site works. You run firebase deploy, open the live URL, and get a polite 404. Firebase Hosting 404s are almost always one of five causes, and each takes about ten minutes to fix once you know which one. The trick is to stop guessing and instead reproduce locally, read firebase.json against the build output, and check the response with curl instead of a cache-poisoned browser tab.

TL;DR

  • Reproduce first: npm run build && firebase serve --only hosting, then curl -sI http://localhost:5000/about/. A 404 here means a build problem; a 200 means a deploy or config problem.
  • The two most common causes are a wrong public directory in firebase.json and a cleanUrls/trailingSlash mismatch with what your framework actually writes to disk.
  • SSR routes never auto-render on classic Hosting. They need a rewrite to a Cloud Function or Cloud Run service, or you move that app to Firebase App Hosting.
  • A stale ** catch-all rewrite to /index.html masks every 404 by serving the homepage HTML. Remove it from static content sites.
  • Firebase Hosting’s Spark plan is free and allowed for commercial use, so none of these fixes require upgrading.

How Firebase decides what to serve

Firebase Hosting (built on the open-source Superstatic engine) evaluates every request in a fixed priority order. Knowing this order tells you exactly where your 404 is coming from. Per the official configuration docs, the sequence is:

  1. Reserved /__/* namespaces (Firebase’s own paths).
  2. Configured redirects, first matching rule wins.
  3. Exact-match static content in your public directory.
  4. Configured rewrites, first matching rule wins.
  5. Your custom 404.html, if present.
  6. The default Firebase 404 page.

So a 404 means the request reached step 6: no static file matched and no rewrite caught it. Every fix below is about getting the request to land on step 3 or 4 instead.

Which cause is it?

SymptomMost likely cause
Some routes work, others 404trailingSlash / cleanUrls mismatch (cause 2)
All routes 404 except /Wrong public directory (cause 1)
404 only on routes meant to be SSR’dMissing rewrite to a Function / Cloud Run (cause 3)
404 only on routes you added todayBuild not run, or you deployed an old artifact (cause 4)
404 on /_astro/... or other hashed assetsBuild output is missing the hashed file (cause 5)

Reproduce locally before you change anything

This single step splits every 404 into “build problem” or “platform problem” and saves you from blind config edits:

npm run build
firebase serve --only hosting
# In another shell:
curl -sI http://localhost:5000/about/ | head -3
# HTTP/1.1 404 ...  ← build output problem (file isn't there)
# HTTP/1.1 200 ...  ← Firebase config / deploy problem (file is there)

Use curl -sI (or firebase emulators:start if you also run Functions) so browser cache never enters the picture. A failing URL you cannot reproduce in curl is usually a cache artifact, not a real 404.

Cause 1: public points at the wrong directory

firebase init defaults public to public/, but most frameworks build elsewhere: Astro and Vite write to dist/, Next.js static export writes to out/. If public is wrong, step 3 above never matches and every route except a coincidental /index.html 404s.

{
  "hosting": {
    "public": "dist",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
    "cleanUrls": true,
    "trailingSlash": true
  }
}

Confirm the route’s file is actually inside that directory:

ls -la dist/about/
# index.html   ← good
# (empty)      ← framework never generated the route
find dist -name 'about*' -type f
# dist/about.html        ← pair with cleanUrls: true
# dist/about/index.html  ← pair with trailingSlash: true

Cause 2: cleanUrls / trailingSlash mismatch

This is the subtle one, and it produces the classic “half my pages work” symptom. The two settings are 301 redirect rules, not just cosmetics:

  • cleanUrls: true drops the .html extension from existing pages and 301-redirects any /about.html request to /about. As of June 2026 it only applies to pages that exist; a missing page must match a redirects rule exactly.
  • trailingSlash: true 301-redirects to add a trailing slash; trailingSlash: false redirects to remove one.

Match the setting to what your framework writes to disk. The four valid combinations:

Framework outputs            firebase.json                                       Live URL
about.html                   cleanUrls: true,  trailingSlash: false            → /about
about.html + /about/ redirect cleanUrls: true, trailingSlash: false            → /about
about/index.html             cleanUrls: false, trailingSlash: true             → /about/
about/index.html             cleanUrls: true,  trailingSlash: true             → /about/  (cleanest)

Astro’s build.format: 'directory' (the default) outputs about/index.html, so pair it with trailingSlash: true. One trap worth flagging: a combination of trailingSlash: false and cleanUrls: false has historically triggered an infinite-redirect loop in Superstatic, so avoid that pair unless you have tested it.

Cause 3: SSR routes have no rewrite

Classic Firebase Hosting serves static files only. It does not run your server code, so any route that should be server-rendered 404s unless you rewrite it to a backend. You have two paths as of June 2026:

Keep classic Hosting and add a rewrite to a Cloud Function or Cloud Run service:

{
  "hosting": {
    "rewrites": [
      { "source": "/api/**", "function": "api" },
      { "source": "/render/**", "run": { "serviceId": "ssr-render", "region": "us-central1" } }
    ]
  }
}

Gotchas that produce a 404 or Function not found even with the rewrite present:

  • The serviceId or region does not match the deployed service. Run firebase functions:list (or check Cloud Run) and confirm both fields match exactly.
  • The backend was never deployed. Deploy both together: firebase deploy --only functions,hosting. For Cloud Run, deploy the service before the Hosting config so the rewrite has a live target.
  • A public Cloud Run service needs --allow-unauthenticated, or callers get an error instead of your page.

Or move to Firebase App Hosting, which went GA in April 2025 and is now Google’s recommended home for SSR frameworks like Next.js and Angular. Note that the older Hosting “web frameworks” experiment is closed to new Next.js participants, so new SSR projects should start on App Hosting rather than wiring rewrites by hand.

Cause 4: you deployed an old artifact

If only today’s new routes 404, the build probably did not run, or CI deployed a stale dist/. Check that the live release matches your latest build:

firebase hosting:releases:list
# release   2026-05-22 14:02   <hash>   ← compare timestamp with your last build

curl -sI https://yourdomain.com/about/ | grep -i x-served-by
# usually shows a release hash you can match against the list

The fix is almost always a CI ordering bug: make sure the workflow runs npm run build before firebase deploy, and that it deploys the build output directory, not the source.

Cause 5: hashed asset 404s

A 404 on /_astro/... or other content-hashed files means the deployed HTML references a hash that is not in the upload, usually a partial deploy or a CDN holding an old index.html that points at filenames the new build renamed. Re-run a clean build and full deploy, then bypass cache while testing:

curl --header 'Cache-Control: no-cache' -sI https://yourdomain.com/_astro/<file>.js | head -3

The catch-all rewrite that hides every 404

Watch for this leftover, common in SPA templates:

{ "source": "**", "destination": "/index.html" }

It silently turns every unmatched path into the homepage HTML. Technically not a 404, but it breaks SEO and deep links because every URL serves the same shell. Astro and most static content frameworks do not need it. Remove it for content sites; keep it only for a genuine client-routed SPA.

Verify the fix

  • Every previously-failing URL now returns 200 via curl -sI.
  • A known-bad URL still returns 404, not 200, which proves your 404.html works.
  • Search Console URL Inspection shows “URL is on Google” or at least “Page fetched” for the affected pages.

FAQ

Why does / work but /about 404? Almost always trailingSlash or cleanUrls. Either the file is about.html and you have not enabled cleanUrls, or the file is about/index.html and trailingSlash is false. Match the setting to what your build writes to disk.

Should I enable cleanUrls? Yes for most content sites; it produces tidier URLs and 301-redirects the .html form for you. Just keep your internal links consistent with the setting so you do not chain redirects.

My route is supposed to be SSR. Why does it 404? Classic Firebase Hosting does not auto-render. You need a rewrite from that path to a Cloud Function or Cloud Run service, or you host the app on Firebase App Hosting. If the rewrite is in firebase.json but the response is still 404 or “function not found”, the serviceId/region almost certainly does not match the deployed backend (Firebase function not found).

My rewrite block looks correct but the URL still serves the static file. A real file at the same path shadows the rewrite, because exact-match static content (step 3) is evaluated before rewrites (step 4). The rule may also sit below a more general one. Walk through why rewrites do not fire.

I see the file in dist/ but it still 404s. Why? The deploy probably did not upload it. Confirm with firebase hosting:releases:list and compare the timestamp to your last build; CI may have skipped the build step or deployed the wrong directory.

Does fixing this require the Blaze plan? No. Firebase Hosting’s Spark plan is free and permitted for commercial use, so static-content 404 fixes cost nothing. You only need Blaze if you add Cloud Functions or Cloud Run for SSR.

Tags: #Indie dev #Firebase #Hosting #Troubleshooting