Skip to main content
VibeShare

Your sitemap is lying — how next-sitemap silently breaks App Router sitemaps

If you have both next-sitemap and a src/app/sitemap.ts, only one of them is being served — and it's probably the wrong one. How to detect and fix the shadowing trap.

We found this bug on VibeShare itself, so this is a confession as much as a tutorial.

We had a carefully built src/app/sitemap.ts — the modern Next.js App Router way to generate a sitemap. It enumerated category pages, blog posts, template pages, international routes. Dynamic, typed, exactly what the docs recommend.

It was never served. Not once.

The trap

Years ago (or one tutorial ago — time moves fast in vibe coding), the project added the next-sitemap package. It runs as a postbuild script and writes static files into public/:

public/sitemap.xml
public/sitemap-0.xml
public/robots.txt

Here's the part nobody tells you: files in public/ win. When a request comes in for /sitemap.xml, Next.js serves the static file from public/ and your app/sitemap.ts route never runs. Same for robots.txt shadowing app/robots.ts. No build warning. No runtime error. Both systems "work" — one of them just silently does nothing.

So our production sitemap was whatever next-sitemap could figure out from the build output: static routes only, stale config, none of the dynamic pages we'd written code to include.

How to check if this is happening to you

Three checks, two minutes:

  1. Look for both systems. Do you have app/sitemap.ts (or .js) and next-sitemap in package.json (look for a postbuild script)? If yes, you have the conflict.
  2. Check public/. Is there a sitemap.xml or robots.txt committed or generated there? That file is what's actually served.
  3. Curl production. curl https://yoursite.com/sitemap.xml — if it's a <sitemapindex> pointing at sitemap-0.xml, that's next-sitemap's signature output. Your App Router sitemap is dead code.

The fix

Pick one system. If you're on App Router, the built-in convention is better: it's typed, it runs server-side so it can query your database, and it needs zero dependencies.

  1. Delete the generated files: public/sitemap.xml, public/sitemap-0.xml, public/robots.txt.
  2. Delete next-sitemap.config.js and remove the postbuild script from package.json.
  3. Make sure app/sitemap.ts and app/robots.ts cover everything the old config did (exclusions, disallow rules).

While you're in there: include your dynamic pages

The whole point of app/sitemap.ts is that it's just a function — it can hit your database. The pattern we use, with a fail-soft guard so a database hiccup never breaks the sitemap:

let projectPages: MetadataRoute.Sitemap = [];
try {
  const supabase = serviceClient();
  const { data } = await supabase
    .from("projects")
    .select("slug, updated_at")
    .eq("status", "approved");
  projectPages = (data ?? []).map((p) => ({
    url: `${siteUrl}/projects/${p.slug}`,
    lastModified: p.updated_at ? new Date(p.updated_at) : new Date(),
    changeFrequency: "weekly",
    priority: 0.8,
  }));
} catch (err) {
  console.error("[sitemap] enumeration failed:", err);
}

Two details that matter:

  • Filter to published content. Only status = approved rows go in. A sitemap that lists draft or rejected pages hands crawlers URLs that return thin or noindexed content.
  • Validate user-derived slugs. If tags or slugs come from user input, whitelist the characters (/^[a-z0-9-]+$/) before they become URLs in your sitemap.

The lesson

The scary thing about this bug class is that everything looks fine. The site works, a sitemap is served, Search Console doesn't complain loudly. You only notice when you ask which sitemap is being served — so go ask. It's one curl.

Back to Blog