Firebase Hosting Rewrites: When to Use Them (and When Not To)

Firebase Hosting rewrites map URLs to a Cloud Function, Cloud Run, or a single-page app. The three patterns that actually work as of June 2026, the modern functionId syntax, and the traps each one hides.

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, then rewrites, 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), and run (a Cloud Run service). There is no site key to mount another Hosting site under a path.
  • As of June 2026 the documented function form 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 redirects with "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:

  1. Reserved namespaces (/__/*)
  2. Configured redirects
  3. Exact static content (a real file at that path)
  4. Configured rewrites (first matching source wins)
  5. 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 504 no matter what timeout you set on the function. Long jobs belong in a background task or queue, not behind a Hosting rewrite.
  • pinTag for 2nd-gen functions. "pinTag": true keeps 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 like docs.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 function syntax. "function": "api" with a sibling "region" is legacy; it deploys but the call resolves no region and 404s. Use { "functionId": "api", "region": "..." }.
  • ** to /index.html on 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 301 redirect tells search engines the page moved.
  • Functions over 60 seconds. Hosting times out at 60s and returns 504 regardless of the function’s own timeout.
  • cleanUrls mismatch. With "cleanUrls": true, /about.html is served at /about, so a rewrite or redirect written against .html may 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.

PlanHosting storageTransfer / monthOverageCommercial use
Spark (free)10 GB10 GB (~360 MB/day)none (hard cap)Allowed
Blaze (pay-as-you-go)10 GB free, then $0.026/GB10 GB free, then $0.15/GBusage-basedAllowed

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 301 or 302 and 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 make region match 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 source that 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 no site rewrite 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 504 past that, even if the function allows longer. Move long work to a background task or queue.

Tags: #Indie dev #Firebase #Hosting #Troubleshooting