By SitemapFixer Team
Published April 2026 · 9 min read

7 Next.js App Router Sitemap Mistakes That Break Google Indexing

See exactly which URLs are missing from your sitemapAnalyze My Site Free

The Next.js App Router makes sitemap generation feel effortless — drop a sitemap.ts file in your app directory and you get a valid XML response at /sitemap.xml. But "valid XML" and "sitemap that actually helps indexing" are very different things. These are the seven mistakes that appear repeatedly when auditing production Next.js sites — each one either causes Google to skip URLs entirely or waste crawl budget on pages it cannot or should not index.

Mistake 1: Absolute URLs That Don't Match the Deployed Domain

The most common sitemap bug in Next.js is also the easiest to miss in local development: hardcoding or constructing URLs that resolve correctly in one environment but produce the wrong domain in production.

The problem pattern looks like this:

// sitemap.ts — DO NOT DO THIS
export default function sitemap() {
  return [
    { url: 'http://localhost:3000/about' },
    { url: `${process.env.NEXT_PUBLIC_BASE_URL}/blog` },
  ];
}

The localhost URL is obvious but the environment variable version is subtler. If NEXT_PUBLIC_BASE_URL is set to a preview deployment URL (https://myapp-git-main-username.vercel.app) or is simply undefined, every URL in your sitemap resolves wrong. Google will crawl those wrong URLs, find them either missing or redirecting, and the sitemap provides no indexing benefit.

The fix is to canonicalize at the source:

// sitemap.ts — CORRECT
const BASE_URL = 'https://yourdomain.com'; // literal string, not env var

export default function sitemap() {
  return [
    { url: `${BASE_URL}/about`, lastModified: new Date() },
    { url: `${BASE_URL}/blog`, lastModified: new Date() },
  ];
}

Use a literal string for your canonical domain in sitemap.ts. Verify in Google Search Console after deploying by clicking through to the actual submitted URLs — any redirect or 404 there confirms this problem.

Mistake 2: Setting changeFrequency and priority to Meaningless Defaults

Google has publicly stated that it ignores changefrequency and priority values, but that doesn't mean they should be set carelessly. The real damage comes from making your sitemap harder to maintain and debug — and from telling tools that do read these values (Bing, some crawlers) that every single page on your site has priority: 1.0 and changeFrequency: 'always'.

More importantly: Next.js will emit these fields in your XML if you include them. If you set changeFrequency: 'always' on static marketing pages, you are suggesting they change on every crawl. Googlebot may increase crawl frequency on those pages, consuming budget it could spend on your actual content pages. Omit these fields entirely for most sites unless you have a real reason to include them:

// Good — only include lastModified, which Google does use
export default async function sitemap() {
  const posts = await getPosts();
  return posts.map((post) => ({
    url: `https://yourdomain.com/blog/${post.slug}`,
    lastModified: post.updatedAt,
    // No changeFrequency, no priority — clean and honest
  }));
}

Mistake 3: Dynamic Routes Not Included Because generateSitemaps() Is Missing

This is the most impactful mistake for content-heavy sites. If your blog lives at app/blog/[slug]/page.tsx, the App Router has no way to know which slugs exist unless you explicitly tell it. A sitemap.ts that doesn't query your database or CMS for those slugs will simply not include them.

The silent failure version:

// sitemap.ts — missing all dynamic routes
export default function sitemap() {
  return [
    { url: 'https://yourdomain.com/' },
    { url: 'https://yourdomain.com/about' },
    // /blog/[slug] routes: completely absent
  ];
}

The fix requires actually fetching your content:

import type { MetadataRoute } from 'next';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await fetch('https://your-cms.com/api/posts')
    .then((r) => r.json());

  const postUrls = posts.map((post: { slug: string; updatedAt: string }) => ({
    url: `https://yourdomain.com/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
  }));

  return [
    { url: 'https://yourdomain.com/', lastModified: new Date() },
    { url: 'https://yourdomain.com/about', lastModified: new Date() },
    ...postUrls,
  ];
}

For sites with thousands of posts, you'll also need generateSitemaps() to split them across multiple sitemaps — covered in Mistake 6 below. The diagnostic check: count the URLs in your sitemap and compare that number to the total number of pages on your site. A large discrepancy means dynamic routes are missing.

Mistake 4: Middleware Redirects Not Reflected in the Sitemap

Next.js middleware runs before the response is generated, which means a URL in your sitemap might redirect to a completely different URL at the middleware layer — and your sitemap generator has no awareness of this.

A common scenario: you internationalize your site and add middleware that redirects /about to /{locale}/about based on the Accept-Language header. Your sitemap still lists https://yourdomain.com/about, which Googlebot hits, gets a 302, and follows to the localized version. The sitemap URL is technically wrong — it points to a redirect, not a final URL.

Another scenario: A/B testing middleware redirects half your traffic from /pricing to /pricing-v2. Your sitemap lists both, but /pricing never actually renders — it always redirects.

The fix: audit your middleware.ts and identify any routes that are unconditionally redirected. Remove those source URLs from your sitemap and replace them with the redirect destinations. For locale-based routing, generate localized sitemap entries directly — list https://yourdomain.com/en/about and https://yourdomain.com/es/about rather than the bare /about that triggers the redirect.

Mistake 5: Including Noindexed Pages in the Sitemap

This creates a direct contradiction: your sitemap says "please index this URL" while the page itself says "do not index me." Google resolves this contradiction by respecting the noindex directive — the page doesn't get indexed — but your sitemap has now flagged an inconsistency that Google's quality signals will record against your site.

In Next.js, noindex is set in metadata like this:

// app/admin/page.tsx
export const metadata = {
  robots: { index: false, follow: false },
};

If your sitemap.ts iterates over all routes without filtering for noindex status, admin pages, preview pages, thank-you pages, and any other intentionally noindexed content all get submitted to Google.

The fix requires knowing which of your routes are noindexed and explicitly excluding them:

// Only include pages explicitly marked as indexable
const NOINDEX_PATHS = ['/admin', '/thank-you', '/preview', '/api'];

export default async function sitemap() {
  const allRoutes = await getAllRoutes();
  const indexableRoutes = allRoutes.filter(
    (route) => !NOINDEX_PATHS.some((path) => route.startsWith(path))
  );
  return indexableRoutes.map((route) => ({
    url: `https://yourdomain.com${route}`,
    lastModified: new Date(),
  }));
}

Mistake 6: Large Sites Not Splitting Into a Sitemap Index via generateSitemaps()

The XML sitemap specification caps a single sitemap at 50,000 URLs and 50MB uncompressed. A Next.js app with a large content library that stuffs everything into a single sitemap.ts export will either silently truncate entries or produce an oversized sitemap that Googlebot partially ignores.

Next.js provides generateSitemaps() specifically for this scenario. It returns an array of IDs, and Next.js generates a separate sitemap file for each ID, then automatically creates a sitemap index at /sitemap.xml that references them all.

// app/sitemap.ts with generateSitemaps()
const POSTS_PER_SITEMAP = 5000;

export async function generateSitemaps() {
  const totalPosts = await getPostCount();
  const sitemapCount = Math.ceil(totalPosts / POSTS_PER_SITEMAP);
  return Array.from({ length: sitemapCount }, (_, i) => ({ id: i }));
}

export default async function sitemap({ id }: { id: number }) {
  const posts = await getPosts({
    offset: id * POSTS_PER_SITEMAP,
    limit: POSTS_PER_SITEMAP,
  });

  return posts.map((post) => ({
    url: `https://yourdomain.com/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
  }));
}

This produces /sitemap/0.xml, /sitemap/1.xml, etc., with a sitemap index at /sitemap.xml that references all of them. Submit the index URL to Google Search Console and all child sitemaps are picked up automatically.

Mistake 7: ISR Cache Causing Stale Sitemaps

When you publish a new blog post, you expect Google to find it quickly. But if your sitemap.ts is cached by Next.js's Incremental Static Regeneration with a long revalidation window, Googlebot may visit /sitemap.xml and receive a version that doesn't yet include the new post — potentially for hours or days.

By default, Next.js Route Handlers (which is what sitemap.ts compiles to) are cached. If you don't explicitly set a revalidation policy, you may get a cached sitemap served to Googlebot indefinitely.

// sitemap.ts — control cache revalidation explicitly
export const revalidate = 3600; // regenerate every hour

export default async function sitemap() {
  const posts = await getPosts(); // fresh data every hour
  return posts.map((post) => ({
    url: `https://yourdomain.com/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
  }));
}

For sites where fresh discovery matters, use export const revalidate = 0 to always fetch live data, or use on-demand revalidation: call revalidatePath('/sitemap.xml') from your CMS webhook handler whenever new content is published.

To check if you're serving a stale sitemap: compare the URL count in your sitemap against your actual published page count. Then check the response headers — if you see X-Nextjs-Cache: HIT with an old Age header, the sitemap is being served from cache and new pages won't appear until the cache expires.

The Quickest Diagnostic: What to Check First

When a Next.js sitemap isn't producing expected indexing results, work through these checks in order:

  1. Fetch /sitemap.xml directly and count the URLs. Compare against expected page count.
  2. Check every URL domain matches your canonical domain exactly — no localhost, no Vercel preview URLs.
  3. Pick 5 random sitemap URLs and visit each one. Verify they return 200, have no noindex tags, and have self-referencing canonicals pointing to the same URL.
  4. Check response headers for X-Nextjs-Cache — if HIT, check the Age value.
  5. In Google Search Console under Sitemaps, compare Submitted URLs vs Indexed URLs. A large gap warrants deeper investigation into each scenario above.

The App Router makes sitemap generation convenient, but that convenience can mask real problems if you're not actively checking what the output contains and how it behaves across environments.

Find every URL your sitemap is missing
Free sitemap analysis — see what Google can and can't find
Analyze My Site Free

Related Guides

Is your sitemap hurting your Google rankings?
Check for free →