You configured 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 deep SPA routes like /dashboard/123 404 instead of serving index.html. The config looks right, but it just doesn’t fire.
Firebase Hosting has a few invisible precedence rules: static files > first matching rewrite > 404.html. Once you internalize the precedence, fixing the right thing is straightforward.
Common causes
Ordered by hit rate, highest first.
1. Wrong rewrite order (first match wins)
{
"rewrites": [
{ "source": "**", "destination": "/index.html" }, // ❌ swallows everything
{ "source": "/api/**", "function": "api" } // never reached
]
}
Most specific first.
How to spot it: ** wildcard before /api/**?
2. Same-path static file takes precedence
If public/api.html exists, requests to /api get the static file, not the rewrite.
How to spot it: ls public/ for files that conflict with rewrite sources.
3. Function region doesn’t match rewrite region
{
"rewrites": [
{ "source": "/api/**", "function": { "functionId": "api", "region": "us-east1" } }
]
}
Function actually deployed in us-central1, rewrite looks in us-east1 → 404.
How to spot it: firebase functions:list shows function region; compare to the rewrite config.
4. Function never actually deployed
Deploy command succeeds, but a single function silently failed to build (similar to firebase-deploy-permission-denied silent fail). Rewrite points to a non-existent function.
How to spot it: firebase functions:list — is the name listed?
5. cleanUrls / trailingSlash changed the path
{
"cleanUrls": true, // /about.html → /about
"trailingSlash": false // /about/ → /about
}
After enabling these, your source patterns must reflect the new paths.
How to spot it: cleanUrls / trailingSlash enabled but rewrite sources use old-style paths.
6. Edited firebase.json but didn’t deploy hosting
firebase deploy --only functions doesn’t update rewrites. Need --only hosting (or full deploy).
How to spot it: What flag did your last firebase deploy use?
Shortest path to fix
Step 1: Reorder — specific first
{
"hosting": {
"public": "dist",
"rewrites": [
{ "source": "/api/**", "function": { "functionId": "api", "region": "us-east1" } },
{ "source": "/webhooks/**", "function": "webhookHandler" },
{ "source": "**", "destination": "/index.html" }
]
}
}
Rule: most specific → most general; the SPA fallback is always last.
Step 2: Remove conflicting static files
ls public/
# If api.html, api/index.html, etc. exist, move them
mv public/api.html /tmp/
firebase deploy --only hosting
Step 3: Align function region
# 1. Function's actual region
firebase functions:list
# 2. Match it in firebase.json
{
"source": "/api/**",
"function": {
"functionId": "api",
"region": "asia-east1"
}
}
Or change the function’s region in code and redeploy.
Step 4: Confirm hosting config was deployed
firebase deploy --only hosting
# Changed both firebase.json and functions:
firebase deploy --only hosting,functions
# See what was deployed
firebase hosting:channel:list
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 = config / deploy issue in prod. Fails locally = config bug.
Step 6: curl to see the real response
curl -v https://your-app.web.app/api/test
# 200 + function body = rewrite working
# 404 + HTML = no rewrite, fell into SPA fallback
# 404 + empty = path doesn't exist anywhere
Step 7: Combine redirects + rewrites for complex cases
{
"redirects": [
{ "source": "/old-api/**", "destination": "/api/:1", "type": 301 }
],
"rewrites": [
{ "source": "/api/**", "function": "api" },
{ "source": "**", "destination": "/index.html" }
]
}
Redirects run first.
Prevention
- Annotate firebase.json (use jsonc or an external doc); document why the rewrite order is what it is
- CI post-deploy step: curl every critical rewrite path
- Don’t put files like
api.html/webhooks.htmlin public/ — too easy to conflict - One region per project; all functions go there; no per-rewrite region needed
- firebase.json changes go through PR review — avoid accidental reordering
- Default
firebase deploydeploys everything; only use--onlywhen you know what changed - Run a full routing test in the emulator before deploying
- After deploy, test against the production URL — don’t trust emulator alone
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 rules 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 HTML body = rewrite didn’t match, request fell to 404.html or the SPA fallback. A 404 with JSON / empty body from the function = rewrite matched but the function returned 404. A 500 = function ran but threw. Different fixes for each case.
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 full firebase deploy). The previous hosting config keeps serving until then.
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 — function deployed in a different region, missing IAM on the production function, CDN cache serving a stale rewrite. Always test the production URL after deploy too.
Q: Why does a fresh deploy still serve the old rewrite for a few minutes?
A: Firebase Hosting’s CDN caches firebase.json decisions at the edge for up to a few minutes. Hard-reload or test in incognito; if you need an immediate flip, run firebase hosting:channel:deploy to a preview channel first and validate before promoting.