Supabase

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.

Mahdi Ben RhoumaMahdi Ben Rhouma
8 min read

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.

text
GET /            -> 200
GET /about       -> 404
GET /about.html  -> 200

Why 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:

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:

json
"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#

bash
firebase deploy --only hosting

3. 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:

text
GET /            -> 200
GET /about       -> 200

curl -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.

json
"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

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote