Rewrites are the most misused feature in Firebase Hosting. The right rewrite makes your single-page app work and sends /api/* to a Cloud Function. The wrong one silently flattens your site’s SEO, returns blank pages, or fails after a clean deploy because the rewrite syntax changed and the old flat form no longer resolves a region. This article covers the three patterns that work as of June 2026, the current functionId object syntax, and the trap each pattern hides.
TL;DR
- Firebase Hosting matches in a fixed order: reserved namespaces, then
redirects, then exact static files, thenrewrites, then your 404. A rewrite only fires when no real file matches the path. - There are exactly three useful rewrite destinations:
destination(a local file, for SPAs),function(a Cloud Function), andrun(a Cloud Run service). There is nositekey to mount another Hosting site under a path. - As of June 2026 the documented
functionform is an object:{ "functionId": "api", "region": "us-central1" }. The old flat"function": "api"plus sibling"region"is legacy and is the top cause of “function not found” after a successful deploy. - For renaming or moving URLs, use
redirectswith"type": 301, not a rewrite. Rewrites keep the URL; search engines never see the change. - Hosting enforces a 60-second request timeout. A function that runs longer returns HTTP
504, even if you set a longer timeout on the function itself.
What a rewrite actually does
A rewrite in firebase.json tells the Firebase CDN: when a request matches path X, serve the response from Y without changing the URL in the address bar. Y can be a local static file, a Cloud Function, or a Cloud Run service. Rewrites run after exact static files but before the 404 fallback, so they catch every URL that does not match a real file on disk.
The full match order, per the Firebase Hosting config reference, is:
- Reserved namespaces (
/__/*) - Configured
redirects - Exact static content (a real file at that path)
- Configured
rewrites(first matchingsourcewins) - Custom 404, then default 404
Two facts fall out of this order and explain most “my rewrite does nothing” reports. First, a real file always wins over a rewrite, so a ** rewrite never overrides /about.html if that file exists. Second, rewrites are matched top to bottom and the first matching source wins, so a broad ** rule placed above a specific /api/** rule will swallow the API path.
The three patterns that work
1. SPA pattern (serve index.html for client-side routes)
Use this only on apps with client-side routing (React Router, Vue Router, SvelteKit SPA mode). Never on a multi-page static site, where it turns every URL into the same HTML and destroys SEO.
{
"hosting": {
"public": "dist",
"rewrites": [
{ "source": "**", "destination": "/index.html" }
]
}
}
The trap: ** also matches /sitemap.xml and /robots.txt, so a crawler that requests your sitemap gets index.html back. Firebase serves real files before rewrites, so as long as sitemap.xml physically exists in dist, it is served directly. The breakage happens when your build does not emit those files. Generate them at build time so they sit on disk, or add a negation so the wildcard skips them:
{
"source": "!/@(sitemap.xml|robots.txt)",
"destination": "/index.html"
}
2. Dynamic backend pattern (Cloud Function or Cloud Run)
Scope the rewrite to a prefix. Never wildcard ** to a function: that routes static assets through your backend, which is slow, billable, and unnecessary. Note the current object syntax for function as of June 2026:
{
"hosting": {
"public": "dist",
"rewrites": [
{ "source": "/api/v1/**", "function": { "functionId": "api", "region": "us-central1" } },
{ "source": "/og/**", "run": { "serviceId": "og-image", "region": "us-west1" } }
]
}
}
The region must match where the function or service is actually deployed. The CLI auto-detects the region from your source when there is exactly one, but if the function is deployed to multiple regions it requires region in the rewrite. The single biggest gotcha here is the legacy syntax: the old flat "function": "api" with a sibling "region" field is what most pre-2024 tutorials show, and it is the top reason a rewrite deploys cleanly yet returns “function not found.” Move to the functionId object form.
Two more limits worth pinning to disk before you ship a backend rewrite:
- 60-second timeout. Per the Cloud Functions on Hosting docs, Hosting enforces a 60-second request timeout. A function that needs more than 60 seconds returns HTTP
504no matter what timeout you set on the function. Long jobs belong in a background task or queue, not behind a Hosting rewrite. pinTagfor 2nd-gen functions."pinTag": truekeeps the function version in lockstep with your static deploy, so a rollback of Hosting also rolls back the function it serves. It is available only for Cloud Functions (2nd gen).
3. Redirect, not rewrite (renaming or moving URLs)
This is the case people get wrong most often. If a URL truly changed, you want search engines to update their index, which requires a real 301. A rewrite keeps the old URL and never signals the move. Use the redirects block:
{
"hosting": {
"redirects": [
{ "source": "/blog/:slug", "destination": "/articles/:slug", "type": 301 },
{ "source": "/old/**", "destination": "/new/:0", "type": 301 }
]
}
}
Redirects are matched before static files and before rewrites, so a 301 always wins. :slug captures one path segment; :0 is the segment matched by **.
What about serving another site under a path?
A common request is mounting a separate docs or status site at /docs/** on your main domain. Firebase Hosting has no rewrite key that points to another Hosting site by name. The valid rewrite destinations are exactly destination, function, and run. Two real options:
- Separate Hosting sites with deploy targets. The multi-site model uses an array of hosting configs, each with its own
target, served on its own site or custom domain (a subdomain likedocs.yourapp.com), not as a path on one domain. - Proxy through Cloud Run. If you genuinely need
/docs/**on the same domain, point that prefix at a Cloud Run service that fetches and returns the upstream content. You own the proxy logic and its cost.
{
"hosting": [
{ "target": "app", "public": "app/dist", "rewrites": [{ "source": "**", "destination": "/index.html" }] },
{ "target": "docs", "public": "docs/dist" }
]
}
Deploy and verify
Deploy, then check from an incognito session with curl so cache and login state do not mislead you:
firebase deploy --only hosting
# Static file is served directly (rewrite does not fire)
curl -sI https://yourdomain.com/about.html | head -1
# HTTP/2 200
# API prefix hits the function
curl -sI https://yourdomain.com/api/v1/health | head -1
# HTTP/2 200
# Old URL returns a real 301 with a Location header
curl -sI https://yourdomain.com/blog/foo | grep -E 'HTTP|location'
# HTTP/2 301
# location: https://yourdomain.com/articles/foo
Watch logs for the first 24 hours. If crawlers hit your backend rewrite for static assets, the rule is too broad:
firebase functions:log --only api --lines 200 | grep -E '\.(png|css|js|ico)' | head
# any output means your wildcard is matching assets it should not
Common pitfalls
- Flat
functionsyntax."function": "api"with a sibling"region"is legacy; it deploys but the call resolves no region and 404s. Use{ "functionId": "api", "region": "..." }. **to/index.htmlon a multi-page site. Every URL becomes the same HTML; Google indexes one page and drops the rest.**to a function. Routes images, CSS, and JS through your backend. Scope to a prefix instead.- Rewrite where a redirect belonged. Rewrite keeps the URL; only a
301redirect tells search engines the page moved. - Functions over 60 seconds. Hosting times out at 60s and returns
504regardless of the function’s own timeout. cleanUrlsmismatch. With"cleanUrls": true,/about.htmlis served at/about, so a rewrite or redirect written against.htmlmay never match.
Cost and limits (as of June 2026)
Firebase Hosting is on the same two billing plans as the rest of Firebase. The free Spark plan allows commercial use and includes 10 GB of stored content and 10 GB of data transfer per month (about 360 MB/day). The pay-as-you-go Blaze plan keeps those free quotas and then bills $0.026 per GB stored and $0.15 per GB transferred above them. Backend rewrites add the underlying Cloud Functions or Cloud Run cost on top; a **-to-function rewrite is expensive precisely because it bills every asset request.
| Plan | Hosting storage | Transfer / month | Overage | Commercial use |
|---|---|---|---|---|
| Spark (free) | 10 GB | 10 GB (~360 MB/day) | none (hard cap) | Allowed |
| Blaze (pay-as-you-go) | 10 GB free, then $0.026/GB | 10 GB free, then $0.15/GB | usage-based | Allowed |
Figures from Firebase pricing; verify before relying on exact numbers.
FAQ
- What is the difference between a rewrite and a redirect?: A rewrite serves different content while the URL in the address bar stays the same. A redirect returns a
301or302and the browser navigates to the new URL. Use a redirect when a page actually moved, because only that updates search engine indexes. - Why does my function rewrite return “function not found” after a clean deploy?: Almost always the legacy flat syntax or a region mismatch. As of June 2026 use
{ "functionId": "api", "region": "us-central1" }and makeregionmatch the function’s deployed region. See Firebase function not found. - Can I rewrite to a Cloud Function and pass query params?: Yes. Query strings are forwarded automatically, and the segments matched by
**arrive in the request URL. - Does the order of rewrites matter?: Yes. Firebase uses the first
sourcethat matches, top to bottom, so specific rules must precede**catch-alls. A rewrite that “does nothing” is usually shadowed by an earlier rule or by a real static file at the same path. Walk the diagnosis in rewrites not firing. - Can I serve another Firebase Hosting site under a path like
/docs/**?: No. There is nositerewrite key. Serve it on a separate site or subdomain via multi-site deploy targets, or proxy the path through a Cloud Run service. - Why does a long-running function behind a rewrite fail?: Hosting caps requests at 60 seconds and returns
504past that, even if the function allows longer. Move long work to a background task or queue.