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:
- Look for both systems. Do you have
app/sitemap.ts(or.js) andnext-sitemapinpackage.json(look for apostbuildscript)? If yes, you have the conflict. - Check
public/. Is there asitemap.xmlorrobots.txtcommitted or generated there? That file is what's actually served. - Curl production.
curl https://yoursite.com/sitemap.xml— if it's a<sitemapindex>pointing atsitemap-0.xml, that'snext-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.
- Delete the generated files:
public/sitemap.xml,public/sitemap-0.xml,public/robots.txt. - Delete
next-sitemap.config.jsand remove thepostbuildscript frompackage.json. - Make sure
app/sitemap.tsandapp/robots.tscover 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 = approvedrows 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.