Firebase Hosting 404s Every Route Except index.html
A static Next.js export deploys cleanly to Firebase Hosting, yet every page but the homepage 404s. Reproduced locally, with the config that actually fixes it.
TL;DR#
A Next.js static export (next export, or output: 'export' in next.config.js) writes one
.html file per route — about.html, not about/index.html. Firebase Hosting only serves the
exact file a request names, so a request for /about matches nothing and falls through to the
404 page; only / works, because it maps to the special-cased index.html. Add
"cleanUrls": true to the hosting block in firebase.json and redeploy: Firebase then serves
about.html for a request to /about, and it still serves the real page for that route, not a
copy of the homepage.
GET / -> 200
GET /about -> 404
GET /about.html -> 200Why it happens#
The Stack Overflow question
(7 votes, over 6,200 views) deploys a Next.js app built with next export to Firebase Hosting
with a bare firebase.json:
"hosting": {
"public": "out",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
]
}No cleanUrls, no rewrites. The asker sees the home page, because it is index.html, but any
other route returns a 404.
Next.js's static export writes a flat file per page. The static exports
guide describes the setting that replaced
next export, output: 'export', and documents the file layout it produces: by default a page
at /blog/post-1 is written to out/blog/post-1.html, not out/blog/post-1/index.html — the
guide's own comment on the trailingSlash option is "change links /me -> /me/ and emit
/me.html -> /me/index.html", which only applies when you turn that option on. Left at its
default, every route becomes a .html file sitting next to its folder, with no folder of its
own.
Firebase Hosting then has to decide what to do with a request for /about when the only file on
disk is about.html. Its own docs state the order Hosting checks, when more than one
configuration option could apply: reserved /__/* paths, then configured redirects, then
exact-match static content, then configured rewrites, then a custom 404 page, then the
default 404 page (see Priority order of Hosting
responses). With
nothing configured beyond public, the only options left in that list are exact-match static
content and the 404 page — and /about is not an exact match for any file Hosting has. /
survives only because Hosting has a built-in special case for the site root resolving to
index.html; no other route gets that treatment for free.
This was reproduced locally with the Firebase Hosting emulator (firebase-tools, emulators:start --only hosting) serving a two-page static export (index.html plus about.html, standing in for
next export's own output) through the asker's exact firebase.json. The emulator returned 200
for /, 404 for /about, and 200 for /about.html — the same split the question reports, down
to / being the one route that needs no configuration at all.
Fix#
1. Turn on cleanUrls#
Add "cleanUrls": true next to public in the hosting block:
"hosting": {
"public": "out",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"cleanUrls": true
}Firebase's own docs on the setting
describe exactly this behaviour: "When true, Hosting automatically drops the .html extension
from uploaded file URLs. If an .html extension is added in the request, Hosting performs a
301 redirect to the same path but eliminates the .html extension." That second half matters
for SEO: once this is live, a stray link to /about.html — from an old bookmark, an external
site, or a crawler that indexed the extension — gets redirected to the canonical /about
instead of serving a duplicate URL for the same content.
2. Redeploy#
firebase deploy --only hosting3. Re-check every route you expected to work, not just one#
A single fixed route is not proof the whole site is fixed: confirm the deepest/most dynamic
route your app has, and any route that previously needed the .html suffix to load.
Verify the fix#
Reproduced against the asker's own firebase.json with cleanUrls added, the same two routes
now resolve the opposite way, and the extension redirects instead of serving a duplicate page:
GET / -> 200
GET /about -> 200curl -I against the deployed site should show the same: a 200 for the extension-less path,
and for the .html path specifically, a 301 rather than a second 200.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
The other answer, and why it is usually the wrong one here#
The question's accepted answer takes a different route: a catch-all rewrite.
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]This also makes /about stop 404ing — Hosting's rewrite step now matches every path and serves
index.html for it. But that is the entire fix, and the entire problem with it for this case:
Hosting returns the home page's markup for /about, not about.html's own pre-rendered content.
Reproduced the same way as above, /about comes back 200 with the home page's body, not the
About page's. That is correct behaviour for a genuine single-page app, where every route is
rendered client-side from one shell and client-side routing takes over after load — which is
exactly the pattern Next.js's own SPA
guide describes as "the app is
served by one HTML file… every route, page transition, and data fetch is handled by JavaScript in
the browser." A next export site that isn't built that way loses its pre-rendered HTML for
every route but the home page: slower first paint, and a search crawler that does not execute
the page's JavaScript sees the home page's content at every URL. cleanUrls keeps each route's
own file; the catch-all rewrite throws them away. Keep the rewrite only if the app is deliberately
a client-rendered SPA with no other server-rendered routes to lose.
If firebase.json controls more than one site or target#
A static export with a single hosting object is the simplest shape firebase.json supports.
Projects that deploy Hosting to more than one site, or alongside Realtime Database and Firestore
rules, often reach for an array of hosting entries with named targets instead — and that shape
has its own way to break the deploy before a single file is served: see Fix "Assertion failed:
resolving hosting target" in
Firebase for what
happens when one of those entries names neither a site nor a target.
If the same project is deployed from GitHub Actions rather than a local machine, Firebase's
newer framework-aware Hosting (source instead of public, used for Next.js apps that keep
server-side rendering rather than exporting to static HTML) needs an experiment flag the CI
runner does not have by default — covered in Firebase webframeworks Not Enabled in GitHub
Actions. That flag is unrelated to this 404: a
fully static export, like the one in this question, never touches it.
And if the app also talks to Firestore once the pages themselves load correctly, a very similarly shaped permissions error is next in line — Firestore denies requests for reasons that have nothing to do with Hosting's routing, covered in Firestore PERMISSION_DENIED: Every Real Cause and Fix.
The underlying rule is the same in all three cases: Firebase Hosting only does what firebase.json
tells it to, and a static export's flat file layout means the one piece of default behaviour
every Next.js developer relies on without noticing — a route resolving to a file — has to be
turned on explicitly, one cleanUrls line at a time.
Further reading: Fix Firestore 'Host has been set in both settings()' error.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix "Assertion failed: resolving hosting target" (Firebase)
firebase deploy says this "should have caused an error earlier". With a single hosting object and a dead login it did, and the CLI threw it away. Both causes, reproduced on firebase-tools 15.32.1, and how to tell them apart.
Fix "c(...) is not a function" on Firebase SSR deploy
A Stack Overflow question with 14 votes and no accepted answer, and the GitHub issue the same reporter filed a month later with minification turned off. Together they show exactly which import fails, and that this bug was never really fixed — only avoided.
auth/invalid-api-key still throws without a real apiKey
The rejection happens one function call earlier than most people expect, before any server, project or emulator is ever reached.


