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)
| Symptom | Most likely cause | Section |
|---|---|---|
| Home OK, refresh subpage gives 404 | SPA fallback rewrite missing | SPA |
| Subpage loads via a link but 404s on refresh / direct hit | SSG output missing that static file, or trailing-slash mismatch | SSG |
Every path under one subpath (e.g. /en/) is 404 | That subpath has no index.html | Subpath |
| First deploy, every path 404s | Wrong public directory | Wrong 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 outputdist; Create React App outputsbuild; Angular CLI outputsdist/<project-name>(pointpublicat the inner folder that containsindex.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
getStaticPathswithfallback: falseand return allpaths.
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.htmlandpublic/en/index.htmlmust 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.jsonsays"public": "public"but your framework outputs todist/orout/.- 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:
firebase.json’s"public"matches your real build output directory.- That directory actually contains
index.htmlplus the subpage files (open it and look). - SPA project: catch-all
rewritesto/index.htmlis present. - SSG project: trailing-slash policy is identical in the framework config and
firebase.json. - Multilingual subpaths: each subpath has its own
index.html. - 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:
- Add the SPA fallback rewrite — fixes ~80% of subpage 404s.
- Inspect the build directory and confirm the files exist — fixes ~15%.
- Verify the
publicfield 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 hostingis 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.jsonchange, runfirebase emulators:start --only hostinglocally before deploying. (The olderfirebase serve --only hostingstill 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.
Related articles
- How to Fix Vercel Build Failed
- Why Your New Website Is Not Showing Up on Google
- Why Submitted Sitemap Does Not Lead to Indexing
Tags: #Firebase #Hosting #Route 404 #Debug