Why your Firebase route 404s after deploy (and how to fix it)

Pages that worked locally return 404 on Firebase Hosting. Use this diagnostic checklist with firebase.json snippets and curl commands to pinpoint the cause.

Your local site works. You deploy. You open the live URL and get a polite 404. Firebase Hosting 404s are almost always one of four causes, and they take ten minutes to fix once you know which one — provided you reproduce locally, inspect firebase.json, and check the actual build output side-by-side.

Background

Firebase Hosting serves files from the public directory declared in firebase.json. When a request comes in, it tries to find a file that matches, then applies cleanUrls and trailingSlash transformations, then checks rewrites. If nothing matches, you get a 404. Most “it works locally but not on Firebase” stories are about the gap between what your build produces and what Firebase actually serves.

How to tell

  • Some routes work, others 404 — almost certainly trailing slash / cleanUrls mismatch.
  • All routes 404 except /public directory is likely wrong.
  • 404 only on routes that should be SSR’d — missing rewrite to a Cloud Function or Cloud Run.
  • 404 only on routes you added today — build was not run, or you deployed an old artifact.
  • 404 on /_astro/... assets — the build output is missing the hashed file (cache mismatch).

Quick verdict

Reproduce locally with firebase serve (or firebase emulators:start), then compare the served URL to what your build outputs. If local reproduces the 404, fix the build; if only production 404s, fix firebase.json or the deploy step.

Before you start

  • firebase-tools is installed and you can run firebase serve --only hosting.
  • You can see both firebase.json and the build output directory.
  • A failing URL is reproducible from curl (so you can isolate browser cache).

Step by step

  1. Reproduce locally. This catches build-output problems instantly:
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
# HTTP/1.1 200 ... ← Firebase config / deploy problem
  1. Verify firebase.json “public” matches your actual build folder. Astro → dist, Next static → out, Vite → dist:
{
  "hosting": {
    "public": "dist",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
    "cleanUrls": true,
    "trailingSlash": true
  }
}

A common mistake is leaving public/ (the default that Firebase initializes with) when your framework outputs to dist/.

  1. Confirm the file actually exists in the build output:
ls -la dist/about/
# index.html  ← good
# (empty)     ← framework did not generate the route
find dist -name 'about*' -type f
# dist/about.html      ← cleanUrls: true expected
# dist/about/index.html ← trailingSlash: 'always' expected
  1. Reconcile cleanUrls and trailingSlash with your framework. The four valid combinations:
Framework outputs                      firebase.json
about.html                             cleanUrls: true,  trailingSlash: false  → /about
about.html + redirect /about/          cleanUrls: true,  trailingSlash: false  → /about
about/index.html                       cleanUrls: false, trailingSlash: true   → /about/
about/index.html                       cleanUrls: true,  trailingSlash: true   → /about/  (cleanest)

Astro build.format: 'directory' outputs about/index.html — pair with trailingSlash: true in firebase.json.

  1. For routes that should be SSR’d, confirm the rewrite is present:
{
  "hosting": {
    "rewrites": [
      { "source": "/api/**", "function": "api" },
      { "source": "/render/**", "run": { "serviceId": "ssr-render", "region": "us-central1" } }
    ]
  }
}

If the rewrite is there but you still see 404 (or Function not found), the function name or region does not match the deployed function — firebase functions:list to verify.

  1. Watch out for a catch-all ** rewrite to /index.html. This silently turns every 404 into an SPA shell:
{ "source": "**", "destination": "/index.html" }

Astro and most static frameworks do not need this — remove it for content sites.

  1. Confirm the deploy actually included the build. Releases list + sample headers:
firebase hosting:releases:list
# release   2026-05-22 14:02   <hash>   ← compare timestamp with last build

curl -sI https://yourdomain.com/about/ | grep -i x-served-by
# usually shows release hash you can match
  1. Bypass cache: test in incognito or with curl --header 'Cache-Control: no-cache'.

Implementation checklist

  • firebase serve reproduces the same 404 (or proves the problem is platform-side).
  • firebase.json “public” matches the framework’s actual output directory.
  • cleanUrls and trailingSlash match the framework’s file naming.
  • No stale ** catch-all rewrite in firebase.json.
  • CI workflow runs npm run build before firebase deploy.

After-launch verification

  • Every previously-failing URL now returns 200 via curl -sI.
  • Search Console URL Inspection shows “URL is on Google” or at least “Page fetched”.
  • Hitting a known-bad URL returns 404, not 200 (proves your 404 page works).

Common pitfalls

  • Pointing public to the source folder — every route that is not in source returns 404.
  • Inconsistent trailing slash between framework and host — pages render at /about/ but links point to /about, half work, half do not.
  • A ** rewrite to /index.html that you forgot you added — every route serves the homepage HTML (technically not a 404, but feels like one).
  • Deploying from CI without running the build first.
  • CDN cache holding an old 404 — purge or wait, but the file is actually there.
  • Authentication-only routes that 404 to logged-out users — confirm if the route requires a session.

FAQ

  • Why does / work but /about 404?: Almost always trailing slash or cleanUrls. Either the file is about.html and you have not enabled cleanUrls, or vice versa.
  • Should I enable cleanUrls?: Yes for most sites — it makes URLs prettier. But keep your internal links consistent with the setting.
  • My route is supposed to be SSR. Why 404?: Firebase Hosting does not auto-SSR. You need a rewrite from that path to a Cloud Function or Cloud Run service. If the rewrite is in firebase.json but the response is still 404 or “function not found”, the function name or region almost certainly does not match the rewrite (Firebase function not found).
  • My rewrite block looks correct but the URL still serves the static file: the rewrite is being shadowed by a real file at the same path, or sits below a more general rule. Walk through why rewrites do not fire.
  • I see the file in dist/ but still 404. Why?: Confirm the deploy actually uploaded it: firebase hosting:channel:list and check release contents. CI may have skipped the build step.

Tags: #Indie dev #Firebase #Hosting #Troubleshooting