Fix Firebase Hosting 404 on Subpages: SPA, SSG, Subpath

Homepage works but /about or a refreshed /blog/post returns 404 on Firebase Hosting? It's almost always firebase.json rewrites. Verified fixes for SPA, SSG, Astro, and Next.js as of June 2026.

You deploy to Firebase Hosting. The homepage opens fine. You navigate to /about or refresh /blog/some-post and get a 404. This is one of the most common Firebase Hosting gotchas, and the cause is almost never Firebase itself — it’s firebase.json rewrites being missing or wrong.

Fastest fix (single-page apps): add a catch-all rewrite to /index.html in firebase.json, then redeploy. That alone fixes roughly 80% of subpage 404s. The full config is in the SPA section below.

Identify which case you’re in (5 seconds)

SymptomMost likely causeSection
Home OK, refresh subpage gives 404SPA fallback rewrite missingSPA
Subpage loads via a link but 404s on refresh / direct hitSSG output missing that static file, or trailing-slash mismatchSSG
Every path under one subpath (e.g. /en/) is 404That subpath has no index.htmlSubpath
First deploy, every path 404sWrong public directoryWrong directory

Why this matters: Firebase Hosting serves an exact-match static file first, and only falls through to your rewrites when no file or directory matches the requested path (per the Hosting config docs). So a 404 means “no matching file, and no rewrite caught it.”

SPA: must rewrite to /index.html

Vue / React / Vite / SvelteKit-SPA apps handle routing in the browser (React Router, Vue Router, etc.). When you hit /about directly, Firebase looks for a file at /about or /about/index.html, finds none, and returns 404. The browser router never gets a chance to run.

Fix — add a catch-all rewrite to index.html:

{
  "hosting": {
    "public": "dist",
    "ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
}

source: "**" matches any request that does not resolve to a real file or directory, and serves index.html so the client router can take over.

Note: "public" must match your actual build output directory. Vite / Vue / React (Vite) typically output dist; Create React App outputs build; Angular CLI outputs dist/<project-name> (point public at the inner folder that contains index.html).

Multi-site projects: if firebase.json uses an array of hosting configs, the rewrites must live inside each site’s object, not at the top level:

{
  "hosting": [
    { "target": "app", "public": "dist", "rewrites": [{ "source": "**", "destination": "/index.html" }] },
    { "target": "docs", "public": "docs/dist", "rewrites": [{ "source": "**", "destination": "/index.html" }] }
  ]
}

SSG: Astro / Next.js static export / Hugo

SSG generates one .html file per page, e.g. /about/index.html. Usually no SPA fallback is needed — each route is a real file. The traps are about file shape, not rewrites.

Trap 1: Astro trailing-slash inconsistency

Astro emits /about/index.html. Visiting /about (no trailing slash) sometimes 404s. Firebase usually auto-redirects with a 301, but edge cases fail.

Fix — in astro.config.mjs:

export default defineConfig({
  trailingSlash: 'always',
  build: { format: 'directory' }
});

format: 'directory' is the part that guarantees the subdir/index.html layout. Keep this consistent with firebase.json (see Trap 4).

Trap 2: Next.js static export path mismatch

The standalone next export command was removed in Next.js 14 (you’ll see "next export" has been removed in favor of "output: export"). As of June 2026 the correct path is to set the output mode in config and run a plain next build. Without trailingSlash: true, Next emits out/about.html rather than out/about/index.html, so Firebase looks for /about as a directory, fails, and 404s.

Fix — in next.config.js:

module.exports = {
  output: 'export',
  trailingSlash: true,
};

Then run next build (no separate export step). The static files land in out/ by default — point firebase.json’s "public" at out.

Need real SSR (you use getServerSideProps, route handlers, or middleware)? Static export can’t do it. See the SSR FAQ below.

Trap 3: Dynamic routes never generated

[slug].tsx (Next) or [slug].astro (Astro) need every route enumerated at build time, or those URLs simply don’t exist in the SSG output and will 404.

Fix:

  • Astro: export getStaticPaths() returning every slug.
  • Next.js (App Router static export): export generateStaticParams().
  • Next.js (Pages Router static export): export getStaticPaths with fallback: false and return all paths.

Trap 4: firebase.json contradicts the build

Even with correct SSG output, mismatched firebase.json settings re-break routing:

{
  "hosting": {
    "public": "out",
    "cleanUrls": true,
    "trailingSlash": true
  }
}

Pick one trailing-slash policy and make the framework and firebase.json agree. Mixing trailingSlash: 'never' in Astro with trailingSlash: true in Firebase produces redirect loops or 404s.

Static multipage sites (one HTML per page)

Simplest case. Usually no rewrites needed:

{
  "hosting": {
    "public": "site",
    "cleanUrls": true,
    "trailingSlash": true
  }
}

cleanUrls: true serves /about.html at /about; trailingSlash: true enforces consistency so /about and /about/ don’t diverge.

Subpath / multilingual deployment

For /zh/ and /en/ to both work:

  • public/zh/index.html and public/en/index.html must both physically exist.
  • If each language is its own SPA, add a fallback per subpath, before the global catch-all (rule order matters — Firebase applies the first matching rule):
"rewrites": [
  { "source": "/zh/**", "destination": "/zh/index.html" },
  { "source": "/en/**", "destination": "/en/index.html" },
  { "source": "**", "destination": "/index.html" }
]

Wrong deploy directory (silly but common)

  • firebase.json says "public": "public" but your framework outputs to dist/ or out/.
  • After deploy, Firebase only sees the placeholder public/ directory, so every real page 404s.

Fix: change "public" to your actual build output directory, then redeploy.

Pre-deploy checklist

In order:

  1. firebase.json’s "public" matches your real build output directory.
  2. That directory actually contains index.html plus the subpage files (open it and look).
  3. SPA project: catch-all rewrites to /index.html is present.
  4. SSG project: trailing-slash policy is identical in the framework config and firebase.json.
  5. Multilingual subpaths: each subpath has its own index.html.
  6. After firebase deploy --only hosting, the “X files deployed” count looks reasonable (not 1 or 2 when you expect dozens).

How to confirm it’s fixed

After redeploying, test from a clean state — Firebase caches HTML on its CDN, so use an incognito window or curl, not a normal refresh:

# Should print: HTTP/2 200
curl -I https://YOUR-SITE.web.app/about/

# Test a deep / refreshed route too
curl -I https://YOUR-SITE.web.app/blog/some-post/

A 200 on a direct hit to a non-home route means the rewrite or static file is now resolving. A lingering 404 after a confirmed redeploy is almost always a still-wrong public directory or a missing static file (open the build folder again).

Shortest fix path

In hit-rate order:

  1. Add the SPA fallback rewrite — fixes ~80% of subpage 404s.
  2. Inspect the build directory and confirm the files exist — fixes ~15%.
  3. Verify the public field points at the real output — fixes the worst 5%.

When it isn’t your fault

  • Firebase Hosting incident — check the Firebase status dashboard.
  • CDN cache lag — in the first few minutes after deploy, old 404 responses may still be cached; test in incognito.
  • Custom-domain DNS not propagated yet (can take up to 24–48 hours after you add the domain).

If the 404 originates from a rewrite-to-function path rather than a static route, the cause is a different layer — see rewrites not firing for rule-order and static-file shadowing, and Firebase function not found for region or name mismatches between firebase.json and the deployed function.

Easy misjudgments

  • “SSG doesn’t need a rewrite” — mostly true, but a trailing-slash mismatch still 404s.
  • “Successful deploy = working” — a green deploy only means files uploaded; routes can still be wrong.
  • “Works in the emulator, so it works in production”firebase emulators:start --only hosting is close but not identical to live Hosting; always smoke-test the deployed URL.
  • “The cache is fine” — Firebase caches HTML by default and a hard refresh may not bypass it; use an incognito window.

Prevention

  • For risky changes, deploy to a preview channel first: firebase hosting:channel:deploy preview. It gives you a temporary URL to test before touching production.
  • After any firebase.json change, run firebase emulators:start --only hosting locally before deploying. (The older firebase serve --only hosting still works but the emulator suite is the current tool.)
  • Don’t name your build output directory public/ — it collides with the Firebase default and hides mistakes.
  • Add a CI step that curl -I-checks a couple of key non-home paths return 200 after deploy.

FAQ

Q: My Astro site on Firebase keeps 404-ing — what now? A: Most of the time it’s a trailing-slash mismatch between astro.config.mjs and firebase.json. Set Astro to trailingSlash: 'always' with build: { format: 'directory' }, set firebase.json to "trailingSlash": true, rebuild, and redeploy.

Q: Does Next.js have to be statically exported for Firebase Hosting? A: Plain Firebase Hosting only serves static files, so a static export (output: 'export') is required there. For real SSR (getServerSideProps, middleware, route handlers) use Firebase App Hosting, which is now Firebase’s recommended way to run server-rendered Next.js and Angular. As of June 2026 new sign-ups for the old “web frameworks” Hosting experiment are closed; existing users are pointed to App Hosting. The legacy Cloud Functions / Cloud Run approach still works but is more manual.

Q: What’s the difference between a rewrite and a redirect? A: A rewrite keeps the URL the same and internally serves another file (the browser address bar doesn’t change). A redirect sends the browser to a new URL with a 3xx status. SPA fallback uses a rewrite, not a redirect.

Q: After deploy I still see the old site. A: Browser cache or Firebase’s CDN cache. Use an incognito window, hard refresh (Cmd+Shift+R / Ctrl+Shift+R), or append ?v=1 to the URL to bust it.

Q: How do I deploy multiple hosting sites in one Firebase project? A: Define an array under hosting in firebase.json (one entry per site, each with its own target, public, and rewrites). Deploy a single site with firebase deploy --only hosting:siteId.

Q: Why does /about work but /about/ 404 (or vice-versa)? A: That’s a trailing-slash split. Your framework generates one shape and firebase.json expects the other. Align trailingSlash on both sides and redeploy.

Tags: #Firebase #Hosting #Route 404 #Debug