You set rewrites in firebase.json — /api/** to a Cloud Function and ** to your SPA’s index.html. After deploy, /api/xxx returns 404 instead of hitting the function, or a deep route like /dashboard/123 404s instead of serving index.html. The config looks right but nothing fires.
Fastest fix (covers ~70% of cases): put your specific rules first and the ** SPA fallback dead last, then redeploy hosting with firebase deploy --only hosting. A ** rewrite listed before /api/** swallows every request, and --only functions never republishes your rewrite rules.
The reason this is confusing is that Firebase Hosting has a fixed precedence pipeline you can’t see in the dashboard. As of June 2026 the official order is:
- Reserved namespace (
/__/*, used by the Firebase SDK auto-config and auth helpers) - Exact-match static content (files in your
public/ build dir) - Configured redirects
- Configured rewrites (first matching rule wins)
- Custom
404.html - Default 404
So a static file at the same path beats both a redirect and your rewrite, a redirect beats a rewrite, and within rewrites only the first matching rule runs. Once you internalize that pipeline, you fix the right layer the first time.
Which bucket are you in?
| Symptom | Most likely cause | Jump to |
|---|---|---|
/api/... returns SPA HTML (your index.html) | ** rewrite is ordered before /api/** | Step 1 |
/api serves a file, not the function | A static file (public/api.html, api/index.html) shadows it | Step 2 |
Function works on its *.run.app / direct URL but 404s through Hosting | Region mismatch between function and rewrite | Step 3 |
Rewrite points to a function name that isn’t in functions:list | Function never actually deployed | Step 3 |
Changed firebase.json but old routing still serves | You ran --only functions, not --only hosting | Step 4 |
Paths broke after enabling cleanUrls / trailingSlash | Source patterns no longer match the rewritten path | see cause 5 |
Common causes
Ordered by hit rate, highest first.
1. Wrong rewrite order (first match wins)
{
"rewrites": [
{ "source": "**", "destination": "/index.html" },
{ "source": "/api/**", "function": { "functionId": "api" } }
]
}
The ** rule above matches everything, so /api/** is never reached. Most specific first; the SPA catch-all is always last.
How to spot it: is a ** source listed before any narrower pattern in your rewrites array?
2. Same-path static file takes precedence
Exact-match static content sits above rewrites in the pipeline. If public/api.html (or api/index.html) exists, a request to /api gets the static file, and your function rewrite never runs.
How to spot it: ls -R public/ (or your build output dir) for files whose path collides with a rewrite source.
3. Function region doesn’t match the rewrite
{
"rewrites": [
{ "source": "/api/**", "function": { "functionId": "api", "region": "us-east1" } }
]
}
If the function is actually deployed in us-central1 but the rewrite names us-east1, Hosting can’t find it and falls through to 404. When region is omitted, the CLI auto-detects it from your function source and defaults to us-central1. If the same function is deployed to multiple regions, the CLI requires you to specify region explicitly.
A related failure: the deploy command reported success but one function silently failed to build, so the rewrite points to a function that doesn’t exist.
How to spot it: firebase functions:list shows each function’s exact name and region. Compare both against the rewrite config.
4. Edited firebase.json but didn’t deploy hosting
firebase deploy --only functions does not republish rewrites, redirects, headers, cleanUrls, or trailingSlash. Those live in the Hosting release. The previous Hosting config keeps serving until you run --only hosting (or a full firebase deploy).
How to spot it: check the flags on your last firebase deploy command.
5. cleanUrls / trailingSlash rewrote the path
{
"cleanUrls": true,
"trailingSlash": false
}
cleanUrls: true drops .html and 301-redirects /about.html to /about. trailingSlash: false 301-removes trailing slashes (/about/ to /about). After enabling either, your rewrite source patterns must match the post-normalization path. Note: trailingSlash only affects static content — it does not apply to rewrites that proxy to Cloud Functions or Cloud Run.
How to spot it: cleanUrls / trailingSlash are on, but your rewrite sources still use the old .html or trailing-slash form.
Shortest path to fix
Step 1: Reorder — specific first
{
"hosting": {
"public": "dist",
"rewrites": [
{ "source": "/api/**", "function": { "functionId": "api", "region": "us-east1" } },
{ "source": "/webhooks/**", "function": { "functionId": "webhookHandler" } },
{ "source": "**", "destination": "/index.html" }
]
}
}
Rule: most specific to most general; the SPA fallback is always the last entry.
Step 2: Remove conflicting static files
ls -R public/
# If api.html, api/index.html, etc. exist, move them out of the deploy dir
mv public/api.html /tmp/
firebase deploy --only hosting
Step 3: Align the function region (and confirm it deployed)
# Function's actual name + region
firebase functions:list
Match that region in firebase.json:
{
"source": "/api/**",
"function": { "functionId": "api", "region": "asia-east1" }
}
Or pin the region in your function code and redeploy. If functions:list doesn’t show the function at all, it never deployed — fix the build error and run firebase deploy --only functions:api.
Step 4: Actually deploy the hosting config
# Republish rewrites only
firebase deploy --only hosting
# Changed both firebase.json and functions
firebase deploy --only hosting,functions
# Confirm the live release + its rewrites
firebase hosting:sites:list
firebase deploy --only hosting automatically clears the Firebase CDN cache for your static content, so updated rewrite rules take effect on the next request — no manual purge needed.
Step 5: Verify with the emulator
firebase emulators:start --only hosting,functions
# Visit http://localhost:5000/api/test — does it hit the function?
Works locally but fails in prod = a config or deploy issue (region, missing function, IAM). Fails locally too = a config bug in firebase.json. Note the emulator routes all rewrites to the default region, so it can’t catch a region mismatch — verify that against prod.
Step 6: curl the real response
curl -v https://your-app.web.app/api/test
Read the result like a decision tree:
200+ function body → rewrite is working.404+ your HTML → no rewrite matched; request fell to404.htmlor the SPA fallback (recheck Step 1 and 2).404+ JSON/empty from the function → rewrite matched but the function itself returned 404.500→ rewrite matched, function ran and threw (check function logs, not the rewrite).
Step 7: Combine redirects + rewrites for complex routing
{
"redirects": [
{ "source": "/old-api/**", "destination": "/api/:1", "type": 301 }
],
"rewrites": [
{ "source": "/api/**", "function": { "functionId": "api" } },
{ "source": "**", "destination": "/index.html" }
]
}
Redirects sit above rewrites in the pipeline, so the 301 fires before any rewrite is considered.
Step 8 (2nd-gen functions): pin the function to the hosting release
If you keep getting version skew — new front-end deployed but the function rewrite serves the old behavior, or a preview channel can’t reach the function — add pinTag to the rewrite. This is 2nd-gen-only.
{
"source": "/api/**",
"function": { "functionId": "api", "region": "us-central1", "pinTag": true }
}
A pinned function is deployed alongside your static resources even on firebase deploy --only hosting, works on preview channels, and rolls back together with the site if you revert a release.
How to confirm it’s fixed
curl -v https://your-app.web.app/api/testreturns200with the function’s body (not HTML).- A deep SPA route like
https://your-app.web.app/dashboard/123servesindex.html(status200, your app shell), not a 404. firebase functions:listshows the function name + region matching the rewrite exactly.- Hard-reload or test in a private window to bypass any browser cache, then re-run the curl.
Prevention
- Document why the rewrite order is what it is (use a JSONC config or a comment in your deploy README); reorder reviews catch the
**-too-early bug. - Add a CI post-deploy step that
curls every critical rewrite path and fails the build on a wrong status. - Don’t put files like
api.html/webhooks.htmlin your deploy dir — they shadow rewrites of the same name. - Standardize on one region; deploy all functions there so no per-rewrite
regionis needed. - Route
firebase.jsonchanges through PR review so an accidental reorder is visible in the diff. - Run a full routing pass in the emulator before deploy, then re-test against the production URL — the emulator can’t reproduce region or IAM problems.
FAQ
Q: Why is my SPA rewrite swallowing the API path?
A: Firebase Hosting matches rewrites top-down, first match wins. A ** source listed before /api/** catches every request and never reaches the API rule. Order from most specific to most general; the ** SPA fallback is always last.
Q: How do I tell whether a 404 came from the rewrite missing or from the function erroring?
A: curl -v the URL. A 404 with an HTML body = the rewrite didn’t match and the request fell to 404.html or the SPA fallback. A 404 with a JSON/empty body from the function = the rewrite matched but the function returned 404. A 500 = the function ran and threw. Each needs a different fix.
Q: Do I need to redeploy hosting after every firebase.json change?
A: Yes. firebase deploy --only functions does not republish hosting config. After editing rewrites, redirects, headers, cleanUrls, or trailingSlash, run firebase deploy --only hosting (or a full firebase deploy). The previous hosting config keeps serving until then.
Q: Does a static file really beat my rewrite?
A: Yes. As of June 2026 the precedence is reserved namespace → exact-match static content → redirects → rewrites → 404. A file at the same path as a rewrite source is served before the rewrite ever runs, so remove or rename conflicting files in your deploy dir.
Q: Does the emulator catch all rewrite bugs? A: It catches config bugs — wrong order, wrong source pattern, missing function — but misses environment-specific ones: a function deployed in a non-default region (the emulator routes everything to the default region), missing IAM on the production function, or a stale CDN/browser cache. Always test the production URL after deploy.
Q: After deploy, a fresh load still serves the old behavior. Why?
A: firebase deploy --only hosting clears the Firebase CDN cache automatically, so static content and rewrite rules flip on the next request. Lingering staleness is usually your browser cache (hard-reload or use a private window) or a Cloud Function/Cloud Run response cached per its own Cache-Control header — those aren’t purged by a hosting deploy. For a safe pre-check, deploy to a preview channel with firebase hosting:channel:deploy preview1 and validate before promoting.