How to Fix "Couldn't Fetch" on a Next.js Sitemap
You build a fresh Next.js 16 site as part of your custom web development rollout, add a sitemap, verify the property in Google Search Console, and go to submit your sitemap index and Google throws back a blunt, unhelpful "Couldn't fetch." No details, no line number, just a red status telling you Google rejected it.
Short version: couldn't fetch Next. js sitemap means that the URL you have submitted displays a 404, a 5xx, or a redirect. Here is the reason why it is so and how to fix it.
What "Couldn't Fetch" Actually Looks Like
What is confusing is that nothing sounds broken at your end.
- You submit your sitemap index URL in GSC → Sitemaps, and the status shows “Couldn’t fetch."
- Your regular
/sitemap.xmlmight even open fine in the browser, which makes the error feel random. - But the specific index URL you submitted, something like
/sitemap-index.xmlor/sitemap_index.xml, returns a 404 when you open it directly.
The Actual Reason
Next.js has a built-in sitemap system: the sitemap.ts file convention, plus generateSitemaps() to divide a large site into the multiple small sitemaps. Google's actual limit is 50,000 URLs per file.
Here's the gap that trips everyone up: Next.js has the ability to generate the child sitemaps, but it doesn’t generate a parent sitemap. So when you are allowed to follow the standard practice, "submit the sitemap index to Google," and enter /sitemap-index.xml, there's nothing there.
This gap has existed across the App Router since Next.js 13, and it is still unresolved in Next.js 16, the current release. Starting a project on the latest Next.js today expecting the sitemap index to "just work"? It won't. You build it yourself, and this is often the point where a Next. js development company gets looped in to patch it as part of a broader launch checklist.
The Solution I Use to Resolve an Issue
Since Next.js won't create the index, you create it. There are two methods in 2026 to resolve the issue.
Option A: a build-time script (recommended)
Generate flat files: sitemap-0.xml, sitemap-1.xml, and a sitemap-index.xml, straight into /public during the build. They exist as real static files at your site root, so the URL you submit to Google actually resolves.
// scripts/generate-sitemaps.ts
import { writeFileSync } from "fs";
import { join } from "path";
import { SITE_URL, PAGES_PER_SITEMAP } from "../lib/site-config";
import { getAllUrls } from "../lib/urls"; // your data source
function buildUrlset(urls: { url: string; lastModified?: string }[]) {
const entries = urls
.map((u) => ` <url><loc>${u.url}</loc>` +
(u.lastModified ? `<lastmod>${u.lastModified}</lastmod>` : ``) + `</url>`)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n` +
`${entries}\n</urlset>`;
}
function buildIndex(count: number) {
const today = new Date().toISOString().split("T")[0];
const entries = Array.from({ length: count }, (_, i) =>
` <sitemap><loc>${SITE_URL}/sitemap-${i}.xml</loc>` +
`<lastmod>${today}</lastmod></sitemap>`).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
`<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n` +
`${entries}\n</sitemapindex>`;
}
const all = getAllUrls();
const chunks = Math.ceil(all.length / PAGES_PER_SITEMAP);
const PUBLIC = join(process.cwd(), "public");
for (let i = 0; i < chunks; i++) {
const chunk = all.slice(i * PAGES_PER_SITEMAP, (i + 1) * PAGES_PER_SITEMAP);
writeFileSync(join(PUBLIC, `sitemap-${i}.xml`), buildUrlset(chunk));
}
writeFileSync(join(PUBLIC, "sitemap-index.xml"), buildIndex(chunks));
console.log(`Generated ${chunks} sitemaps, ${all.length} URLs`);
Run it before next build (a task any Node.js developer on your team can wire into CI in minutes):
// package.json
"scripts": {
"build": "npx tsx scripts/generate-sitemaps.ts && next build"
}Now https://your-site.com/sitemap-index.xml is a real file returning 200 OK, exactly what Google needs to fetch it.
Option B: a custom route handler (only if your URLs change constantly)
If your URL set changes constantly and you’d rather not redeploy for every change, keep generateSitemaps() for the children and add a route handler that outputs the index on the fly:
// app/sitemap_index.xml/route.ts
import { SITE_URL } from "@/lib/site-config";
export async function GET() {
const count = await getSitemapCount(); // however you count children
const today = new Date().toISOString().split("T")[0];
const items = Array.from({ length: count }, (_, i) =>
` <sitemap><loc>${SITE_URL}/sitemap/${i}.xml</loc>` +
`<lastmod>${today}</lastmod></sitemap>`).join("\n");
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n` +
`<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n` +
`${items}\n</sitemapindex>`;
return new Response(xml, {
headers: { "Content-Type": "application/xml" },
});
}Which is the Reliable Approach
Option A is the better choice for the entire site, and here is why:
- Real static files at the root. The index lives at /sitemap-index.xml as an actual file, so the URL you submit always resolves, no more 404, no more “Couldn’t fetch.” It also sidesteps the sitemaps.org directory-level rule that nested /sitemap/ paths can trip over.
- Zero cost at request time. Option A writes plain XML once, at build. Option B runs code on every crawler request, slower and more expensive as your URL count grows.
- Simpler to keep correct. One script, one output, one source of truth, fewer moving parts than a route handler whose child IDs must match your generator in two places.
The only reason to reach for Option B is a truly dynamic catalog where rebuilding on every change is impractical. If a rebuild on deploy is acceptable, and for most content and programmatic sites it is, use Option A.
Still getting “Couldn’t fetch” after adding the index?
If your index file now returns 200 in the browser but Google still says “Couldn’t fetch,” it’s one of these, all real, all common:
1. Middleware is intercepting the sitemap. If your middleware.ts does auth, i18n, or geo-redirects, it can catch the sitemap/robots paths and redirect Googlebot before it ever reads the file. Exclude metadata routes from the matcher:
// middleware.ts
export const config = {
matcher: [
// match everything EXCEPT next internals, api, static files,
// and the metadata routes crawlers fetch directly
"/((?!_next|api/|.*\\.|sitemap|sitemap-\\d+\\.xml|sitemap-index\\.xml|robots\\.txt).*)",
],
};
This is a common blind spot a DevOps developer reviewing your deployment pipeline would usually catch early.
2. Google cached the earlier failure. Once GSC fails to fetch a URL, it can cache that result, so resubmitting the exact same URL may not trigger a fresh attempt. Submit a slightly different URL (a trailing slash, or a freshly-named file Google hasn’t seen) to force a new fetch.
3. Wrong Content-Type. The response must be served as application/xml. A wrong header can make Google ignore or fail the file.
4. robots.txt unreachable. If /robots.txt returns a 500 or is blocked, Google may report site-wide availability issues that cascade into sitemap fetch failures (often a sign your cloud infrastructure or hosting config needs a second look). Confirm it returns 200.
Two gotchas that may bite you
The .gitignore trap. If you generate sitemaps into /public at build time, gitignore them; otherwise, a teammate running npm run build locally commits their stale, localhost-flavored sitemaps on top of yours.
# .gitignore
public/sitemap-[0-9]*.xml
public/sitemap-index.xml
Submit ONE sitemap source to Google. Pick either your generated index or the App Router sitemap.xml, never both. Two competing sources is a classic way for things to silently drift. If you use the build-time script, delete app/sitemap.ts so it can’t serve a rival file.
How to verify it worked
- Open /sitemap-index.xml directly: it must list every child and return 200 OK.
- Confirm the response header is Content-Type: application/xml.
- Make sure robots.txt points at the index and doesn’t block any child path.
- Submit only the index URL in GSC. The status should move off “Couldn’t fetch” to “Success” once Google re-fetches.
Quick comparison
If you just want the short version, here’s when to reach for each approach:
Wrap Up
The next js sitemap couldn't fetch error on a Next.js 16 sitemap index almost always means the index URL you submitted doesn't exist, because Next.js generates child sitemaps but never the parent index. Generate the index yourself a build-time script for most sites, a route handler for truly dynamic ones serve it as real XML at the site root, keep a single source of truth, and submit just the index. Small amount of code, and the red error finally turns green.
In practice, a broken sitemap rarely shows up alone. It usually sits alongside stale redirects, missing structured data, or sluggish Core Web Vitals nobody's gotten around to, the kind of technical debt that piles up fast in ongoing product development. That's when teams stop patching bugs one at a time and bring in outside help. We've fixed this exact issue for a few clients at Amrood Labs if you'd rather hire dedicated Next.js developer support than dig through this yourself, it's the kind of thing we handle regularly.



.png)







