A sitemap is one of those things that looks simple until you ship a static build.

If your site is deployed as static files (for example, a static-exported Next.js app), you get great performance and cheap hosting—but you lose the ability to generate server-side content at request time. That becomes a problem the moment your sitemap depends on data that changes frequently, like:

  • product URLs coming from a catalog API
  • landing pages coming from a CMS API
  • articles, guides, help pages, or “collections” coming from any REST source
  • anything created/updated outside your frontend build pipeline

This article explains a general-purpose, REST API–based approach to generate a dynamic sitemap.xml while keeping the rest of the site static on Firebase Hosting, using Firebase Cloud Functions as the runtime component.


Why static hosting breaks dynamic sitemaps

When you deploy static assets, there’s no server rendering on request. So if your sitemap should include fresh URLs from APIs, you have two options:

  1. Rebuild & redeploy constantly (cron build, CI, etc.)
  2. Generate sitemap dynamically via a serverless endpoint

Option #2 is often cleaner if URLs change frequently, your data comes from multiple APIs, or you want to avoid “deploy just to refresh sitemap”.


The architecture (high level)

Request flow in production:

  1. A crawler (or browser) requests:
    https://your-domain.com/sitemap.xml
  2. Firebase Hosting rewrites that request to a Cloud Function.
  3. The Cloud Function:
  • calls one or more REST endpoints
  • transforms the response into sitemap URLs
  • returns valid XML with Content-Type: application/xml

Everything else stays static: your app, images, CSS, JS, pre-rendered pages.


What you’ll build

  • A Cloud Function called sitemap that returns XML
  • A Firebase Hosting rewrite rule so /sitemap.xml is served by that function

Step 1: Add a Firebase Hosting rewrite for /sitemap.xml

In your firebase.json, configure Hosting to route requests to a function:

{
  "hosting": {
    "rewrites": [
      { "source": "/sitemap.xml", "function": "sitemap" }
    ]
  }
}

That’s the key move: Firebase Hosting remains static, but /sitemap.xml is now “dynamic”.


Step 2: Implement a general-purpose sitemap Cloud Function

The function’s job is straightforward:

  1. Define static routes you always want indexed
  2. Fetch dynamic routes from REST APIs
  3. Convert them into <url> entries
  4. Return valid XML + appropriate headers

Here’s a generic example (Node.js):

const { onRequest } = require("firebase-functions/v2/https");
const axios = require("axios");

// Change these to match your domain and REST endpoints
const SITE_URL = "https://example.com";

// Example REST APIs (replace with your own)
const API_ENDPOINTS = {
  products: "https://api.example.com/products",
  categories: "https://api.example.com/categories",
  pages: "https://api.example.com/pages"
};

// A conservative slugify helper you can reuse.
// If your API already returns slugs, prefer those instead.
function slugify(text) {
  return String(text)
    .toLowerCase()
    .replace(/&/g, " and ")
    .replace(/['\"`]/g, "")
    .replace(/[^a-z0-9\s-]/g, " ")
    .replace(/\s+/g, "-")
    .replace(/-+/g, "-")
    .replace(/^-|-$/g, "");
}

function toIsoDate(dateLike) {
  const d = dateLike ? new Date(dateLike) : null;
  return d && !Number.isNaN(d.valueOf()) ? d.toISOString() : null;
}

function renderSitemapXml(entries) {
  const lines = [];
  lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
  lines.push(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`);

  for (const e of entries) {
    lines.push(`  <url>`);
    lines.push(`    <loc>${e.loc}</loc>`);
    if (e.lastmod) lines.push(`    <lastmod>${e.lastmod}</lastmod>`);
    if (typeof e.priority === "number") lines.push(`    <priority>${e.priority.toFixed(1)}</priority>`);
    lines.push(`  </url>`);
  }

  lines.push(`</urlset>`);
  return lines.join("\n");
}

exports.sitemap = onRequest(async (req, res) => {
  // Only allow GET
  if (req.method !== "GET") {
    res.status(405).json({ message: "Method not allowed" });
    return;
  }

  // Optional: simple cache header (CDNs and crawlers will reuse)
  res.set("Content-Type", "application/xml");
  res.set("Cache-Control", "public, max-age=3600"); // 1 hour

  try {
    // 1) Static URLs (always present)
    const staticEntries = [
      { loc: `${SITE_URL}/`, priority: 1.0 },
      { loc: `${SITE_URL}/about/`, priority: 0.6 },
      { loc: `${SITE_URL}/contact/`, priority: 0.6 }
    ];

    // 2) Fetch dynamic sources from REST APIs
    // Run requests in parallel for speed
    const [productsResp, categoriesResp, pagesResp] = await Promise.all([
      axios.get(API_ENDPOINTS.products, { timeout: 10000 }),
      axios.get(API_ENDPOINTS.categories, { timeout: 10000 }),
      axios.get(API_ENDPOINTS.pages, { timeout: 10000 })
    ]);

    const products = Array.isArray(productsResp.data) ? productsResp.data : [];
    const categories = Array.isArray(categoriesResp.data) ? categoriesResp.data : [];
    const pages = Array.isArray(pagesResp.data) ? pagesResp.data : [];

    // 3) Convert API items into sitemap entries
    // Prefer using `slug` from API if available; fallback to `slugify(title)`
    const productEntries = products
      .filter(p => p && (p.slug || p.name || p.title))
      .map(p => ({
        loc: `${SITE_URL}/product/${encodeURIComponent(p.slug || slugify(p.name || p.title))}/`,
        lastmod: toIsoDate(p.updatedAt || p.updated_on || p.updatedOn || p.createdAt),
        priority: 0.8
      }));

    const categoryEntries = categories
      .filter(c => c && (c.slug || c.name))
      .map(c => ({
        loc: `${SITE_URL}/category/${encodeURIComponent(c.slug || slugify(c.name))}/`,
        lastmod: toIsoDate(c.updatedAt || c.updated_on || c.updatedOn || c.createdAt),
        priority: 0.7
      }));

    const pageEntries = pages
      .filter(pg => pg && (pg.path || pg.slug || pg.title))
      .map(pg => {
        // If your API returns a full path like "/help/shipping", use it directly.
        const path = pg.path
          ? String(pg.path).startsWith("/") ? pg.path : `/${pg.path}`
          : `/page/${encodeURIComponent(pg.slug || slugify(pg.title))}/`;

        return {
          loc: `${SITE_URL}${path.endsWith("/") ? path : `${path}/`}`,
          lastmod: toIsoDate(pg.updatedAt || pg.updated_on || pg.updatedOn || pg.createdAt),
          priority: 0.6
        };
      });

    const xml = renderSitemapXml([
      ...staticEntries,
      ...productEntries,
      ...categoryEntries,
      ...pageEntries
    ]);

    res.status(200).send(xml);
  } catch (err) {
    // Reliability principle: never fail hard for sitemap.xml
    // Even if APIs are down, return a minimal valid sitemap.
    const xml = renderSitemapXml([{ loc: `${SITE_URL}/`, priority: 1.0 }]);
    res.status(200).send(xml);
  }
});

This is intentionally generic. The only things you swap are:

  • your domain (SITE_URL)
  • your REST endpoints
  • how you map API fields → paths

Step 3: Decide how you want to “source truth” for slugs

With REST APIs you generally have two sane approaches:

Option A: API returns canonical slugs (recommended)

Your backend provides stable slug fields. The sitemap just uses them.

Pros:

  • no front-end slug rules to maintain
  • URL changes are controlled centrally

Option B: Frontend slugifies titles

Use slugify(title) in the function.

Pros:

  • easy to ship quickly if API has only titles
    Cons:
  • if slug rules change, URLs change (and that can create SEO churn)
  • punctuation and edge cases can create mismatches

If you must generate slugs on the fly, lock down the rules early and keep them consistent with your routing.


Step 4: Caching and reliability (what matters in practice)

A dynamic sitemap endpoint will get hit by crawlers and monitoring tools. Two best practices:

1) Cache the response

At minimum, use:

  • Cache-Control: public, max-age=3600

This reduces costs and avoids hitting your APIs too frequently.

2) Always return valid XML

Even if APIs fail, return a smaller sitemap (static URLs only). Search engines prefer a “partial sitemap” over an endpoint that errors.


Step 5: Local testing with Firebase emulators

Use the Hosting emulator so you test the real rewrite behavior:

  • http://127.0.0.1:5000/sitemap.xml

If it works through port 5000, your production routing is likely correct.


Common pitfalls (and how to avoid them)

1) “Why not just use Next.js API routes?”

If you export a site (static output), Next.js API routes don’t ship. Firebase Functions fill that gap cleanly.

2) Inconsistent URL behavior between SPA navigation and refresh

Client-side navigation can mask missing Hosting rewrites. Always test:

  • open in a new tab
  • refresh on a deep route
  • direct paste into address bar

3) Slug mismatch between backend and frontend

If the sitemap generates /product/foo-bar/ but your app expects /product/foo-bar-baz/, crawlers will get 404s. Align routing and slug rules (prefer API-provided slugs).

4) Secrets in code

If your REST API needs auth, avoid hardcoding secrets. Use environment config / secret manager patterns.


When this pattern is ideal

This approach is a strong fit when:

  • your frontend is static on Firebase Hosting
  • your URL set changes frequently
  • dynamic URLs are sourced from one or more REST APIs
  • you want SEO-friendly, crawlable sitemap XML without rebuilding the whole app

Wrap-up

A dynamic sitemap doesn’t require moving your entire site to server rendering.

Instead:

  • keep the site static for speed and simplicity
  • add a single serverless function for /sitemap.xml
  • fetch dynamic URLs from REST APIs
  • return standard XML with caching

This gives you the best of both worlds: static hosting and dynamic SEO infrastructure.

Leave a comment