Fix Your Sitemap for Next.js
Next.js gives you two ways to ship a sitemap: the App Router's built-in sitemap.ts convention, or the next-sitemap package on Pages Router. Both work. Neither is zero-config for sites with dynamic routes, ISR, or i18n.
The most common Next.js sitemap failure isn't a bug - it's silence. You ship a sitemap.ts that returns static routes only, forget to query the CMS for dynamic ones, and Google indexes exactly what you listed (usually the 5 marketing pages) while ignoring the 2,000 blog posts you actually want ranked.
Recently helped a SaaS company running Next.js 14 App Router with 8,600 programmatic "compare X vs Y" pages. Their sitemap had 12 URLs. The sitemap.ts file listed the static marketing pages and missed the dynamic route entirely because the dev had never added the DB query. After fixing, GSC went from 9 indexed pages to 6,100 in about six weeks.
App Router sitemap.ts - the good way
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { db } from '@/lib/db';
export const revalidate = 3600; // rebuild hourly
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const staticPages = [
{ url: 'https://example.com', lastModified: new Date(), priority: 1 },
{ url: 'https://example.com/about', lastModified: new Date() },
{ url: 'https://example.com/pricing', lastModified: new Date() },
];
const posts = await db.post.findMany({
where: { published: true, publishedAt: { lte: new Date() } },
select: { slug: true, updatedAt: true },
});
const blogPages = posts.map((p) => ({
url: `https://example.com/blog/${p.slug}`,
lastModified: p.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.7,
}));
return [...staticPages, ...blogPages];
}Sharding past 50k URLs
// app/sitemap.ts
export async function generateSitemaps() {
const count = await db.post.count();
const shards = Math.ceil(count / 40000);
return Array.from({ length: shards }, (_, id) => ({ id }));
}
export default async function sitemap({ id }: { id: number }) {
const posts = await db.post.findMany({
skip: id * 40000,
take: 40000,
orderBy: { id: 'asc' },
});
return posts.map(p => ({
url: `https://example.com/blog/${p.slug}`,
lastModified: p.updatedAt,
}));
}Next.js auto-generates /sitemap.xml as the index and /sitemap/0.xml, /sitemap/1.xml... as shards. Submit only the index URL to GSC.
Common Next.js Sitemap Issues
sitemap.tshardcoded with static URLs, missing dynamic CMS routes- No
revalidateset - sitemap is rebuilt on every request, slow and unnecessary - Pages Router sites using stale
next-sitemap.config.jsafter migrating some routes to App Router - i18n routes duplicated without
alternatesmetadata - Preview deployments (Vercel
*.vercel.appURLs) indexed alongside production - Draft posts from a CMS leaking because the query didn't filter on
status - Sitemap returning URLs from the dev database when
NEXT_PUBLIC_SITE_URLis undefined in prod - Static export (
output: 'export') sites where sitemap.ts runs at build but the CMS data is stale
i18n alternates
// app/sitemap.ts - with hreflang
const locales = ['en', 'de', 'fr'];
return posts.flatMap(p => locales.map(loc => ({
url: `https://example.com/${loc}/blog/${p.slug}`,
lastModified: p.updatedAt,
alternates: {
languages: Object.fromEntries(
locales.map(l => [l, `https://example.com/${l}/blog/${p.slug}`])
),
},
})));Vercel preview deploys and robots
Every Vercel preview URL (branch-name-team.vercel.app) is crawlable by default. Vercel adds X-Robots-Tag: noindex automatically on preview/development environments - verify with curl -I https://branch.vercel.app and look for the header. If you've customized vercel.json or removed defaults, you can accidentally expose previews. On custom Next.js deployments (not Vercel), add the header via middleware based on process.env.VERCEL_ENV !== 'production'.
Step-by-Step Fix Guide
- Create
app/sitemap.ts(App Router) or installnext-sitemap(Pages Router) - Query every data source for live URLs (CMS, DB, headless content)
- Filter drafts and future-dated content at the query level
- Set
revalidateso the sitemap rebuilds on a sane schedule (1-24 hours) - For 50k+ URLs, use
generateSitemaps()to shard - Add
alternates.languagesfor i18n routes - Confirm Vercel preview URLs return
X-Robots-Tag: noindex - Verify with
curl https://yoursite.com/sitemap.xml - Submit the index URL to Google Search Console