DEV Community

Cover image for Building a Sitemap That Can't Go Stale: A Config-Driven Architecture for Next.js App Router
NxFold Technology LLC
NxFold Technology LLC

Posted on

Building a Sitemap That Can't Go Stale: A Config-Driven Architecture for Next.js App Router

Building a Sitemap That Can't Go Stale: A Config-Driven Architecture for Next.js App Router

Every team that ships a marketing site on Next.js eventually writes the same file: sitemap.ts. It loops over some blog posts, hardcodes a few static paths, and gets shipped without much thought. Six months later, someone notices Google has indexed /dashboard/settings, three new landing pages never made it into the sitemap, and nobody can explain why the homepage and a random FAQ page both have priority: 0.8.

This isn't a hypothetical. It's the natural failure mode of treating a sitemap as a one-off script instead of a piece of infrastructure. After running into exactly this problem on a bilingual (English/Arabic), App Router-based Next.js app with a large admin console living in the same codebase as the public marketing site, I want to walk through the architecture that fixed it: colocated route metadata, a build-time drift check, locale-aware hreflang generation, and a human-readable sitemap rendered through XSLT.

None of this is exotic. It's mostly discipline, encoded so that the discipline can't be skipped.

The problem with the "loop over content" sitemap

The default pattern most tutorials show looks like this:

// app/sitemap.ts
import type { MetadataRoute } from 'next'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts()

  return [
    { url: 'https://example.com', priority: 1 },
    { url: 'https://example.com/about', priority: 0.5 },
    ...posts.map((post) => ({
      url: `https://example.com/blog/${post.slug}`,
      lastModified: post.updatedAt,
      priority: 0.6,
    })),
  ]
}
Enter fullscreen mode Exit fullscreen mode

This works fine for a five-page site. It breaks down once you have three categories of pain happening at once:

  1. Route sprawl. A real app router tree has dozens of routes across marketing pages, a blog, case studies, service/industry landing pages, and — critically — a private admin console that lives under the same domain and the same [locale] segment as the public pages.
  2. Locale multiplication. Every public route exists in at least two languages. A sitemap that only emits English URLs is silently telling search engines that half your site doesn't exist.
  3. Metadata drift. Priority, change frequency, and indexability decisions get made once, in someone's head, and never revisited when new pages are added.

The fix isn't a smarter loop. It's moving sitemap metadata out of a single centralized file and into the same place as the route it describes, then verifying at build time that nothing was left out.

Architecture overview

At a high level, the system has four moving parts:

flowchart LR
    A[Route files in app/ directory] --> B[Colocated sitemapConfig metadata]
    B --> C[Build-time manifest generator]
    C --> D{Drift check}
    D -->|New page missing config| E[Build warning / CI failure]
    D -->|All routes accounted for| F[routes.manifest.json]
    F --> G["/sitemap.xml route handler"]
    G --> H[Per-locale URL + hreflang expansion]
    H --> I[XML response + XSL stylesheet]
    I --> J[Search engines / browsers]
Enter fullscreen mode Exit fullscreen mode

The important design decision here is where the metadata lives. Instead of a central sitemap.ts that has to know about every route in the app, each route file exports a small, typed object describing itself:

// app/[locale]/blog/[slug]/page.tsx
export const sitemapConfig = {
  priority: 0.6,
  changeFrequency: 'monthly',
} as const

export async function generateStaticParams() {
  const posts = await getPublishedPosts()
  return posts.map((post) => ({ slug: post.slug }))
}
Enter fullscreen mode Exit fullscreen mode

A static page that never changes gets something simpler:

// app/[locale]/privacy/page.tsx
export const sitemapConfig = {
  priority: 0.3,
  changeFrequency: 'yearly',
  noIndex: true,
} as const
Enter fullscreen mode Exit fullscreen mode

And an admin page — anything under a route group like (admin) — simply doesn't export sitemapConfig at all, because it isn't supposed to be in the sitemap in the first place.

This colocation matters more than it looks. When a developer adds a new marketing page, the sitemap behavior for that page is decided in the same pull request, by the same person, sitting right next to the code that renders it. There's no separate "update the sitemap" step to forget.

The drift check

Colocated metadata alone doesn't solve the problem — it just moves it. You still need something that notices when a page exists on disk but has no sitemapConfig, or when sitemapConfig exists but the route was deleted. That's the job of a small build-time script that walks the app/ directory, matches page files against a manifest, and either updates the manifest or fails loudly.

// scripts/generate-route-manifest.ts
import { globSync } from 'glob'
import path from 'node:path'

type ManifestEntry = {
  route: string
  hasSitemapConfig: boolean
  isDynamic: boolean
  isAdmin: boolean
}

function buildManifest(): ManifestEntry[] {
  const pageFiles = globSync('src/app/[locale]/**/page.tsx')

  return pageFiles.map((file) => {
    const route = toRoutePath(file)
    return {
      route,
      hasSitemapConfig: fileExportsSitemapConfig(file),
      isDynamic: route.includes('['),
      isAdmin: file.includes('(admin)'),
    }
  })
}

function reportDrift(manifest: ManifestEntry[]) {
  const missing = manifest.filter(
    (entry) => !entry.isAdmin && !entry.hasSitemapConfig
  )

  if (missing.length > 0) {
    console.warn(
      `⚠ ${missing.length} public route(s) have no sitemapConfig:`,
      missing.map((m) => m.route)
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

The output of this script is a static JSON manifest checked into the repo (or generated in CI). Crucially, it's not a runtime dependency — it runs once at build time, and the sitemap route handler simply reads the result. This keeps the actual /sitemap.xml request fast; it's not walking the filesystem on every crawl.

The warning-instead-of-hard-failure choice matters too. A hard failure on missing metadata sounds appealing until a designer adds a one-off internal tools page and the entire build breaks over a sitemap entry nobody cares about. A warning that shows up in build logs and CI output gets seen without blocking shipping — and if a team wants stricter enforcement, turning it into a failure for specifically the (public) and default route groups is a one-line change.

Locale-aware sitemap generation

This is the part most sitemap tutorials skip entirely, and it's the part that actually causes indexing problems in production. If your app uses [locale] segment routing (the standard pattern with next-intl or similar libraries), every URL in your sitemap needs to exist once per locale, and each locale variant needs to point at its siblings via hreflang alternates.

graph TD
    subgraph "One logical page"
        EN["/en/blog/scaling-postgres"]
        AR["/ar/blog/scaling-postgres"]
    end
    EN -- "hreflang=ar" --> AR
    AR -- "hreflang=en" --> EN
    EN -- "hreflang=x-default" --> EN
Enter fullscreen mode Exit fullscreen mode

Get this wrong in either direction and you get one of two failure modes:

  • Only the default locale is in the sitemap. Search engines discover the other locale through links eventually, but crawl budget and indexing lag suffer, and international search visibility is worse than it should be.
  • Locales are listed with no hreflang alternates. Google may treat /en/blog/x and /ar/blog/x as unrelated pages competing for the same query, instead of understanding they're translations of one another.

The sitemap protocol handles this through <xhtml:link rel="alternate" hreflang="..."> entries inside each <url> block. Expanding the manifest into locale variants looks roughly like this:

const locales = ['en', 'ar'] as const

function expandForLocales(entry: ManifestEntry, baseUrl: string) {
  return locales.map((locale) => ({
    loc: `${baseUrl}/${locale}${entry.route}`,
    alternates: locales
      .filter((l) => l !== locale)
      .map((altLocale) => ({
        hreflang: altLocale,
        href: `${baseUrl}/${altLocale}${entry.route}`,
      }))
      .concat({ hreflang: 'x-default', href: `${baseUrl}/en${entry.route}` }),
    priority: entry.priority,
    changeFrequency: entry.changeFrequency,
  }))
}
Enter fullscreen mode Exit fullscreen mode

For dynamic routes (blog posts, service pages, case studies), this expansion happens against each item returned from generateStaticParams, not against the route pattern itself — so a blog post that's only published in English so far doesn't get a broken Arabic sitemap entry pointing at a 404.

Why a route handler instead of the built-in sitemap.ts

Next.js ships a built-in Metadata Files API for sitemaps, including support for splitting large sitemaps via generateSitemaps. For a lot of sites, that's the right tool and you should reach for it first. The custom route handler approach earns its complexity in specific circumstances:

Concern app/sitemap.ts (built-in) Custom route handler
Simple, single-locale site Ideal Overkill
Multi-locale hreflang alternates Possible, but you build the XML shape yourself either way Same effort, more control over structure
Human-readable output via XSL Not supported natively Full control — serve a linked .xsl stylesheet
Splitting across multiple sitemap files (10,000+ URL limit) Built-in via generateSitemaps Manual, but straightforward with a sitemap index file
Excluding whole route groups (e.g. admin) automatically Manual filtering in the function body Manual filtering, but colocated with route metadata
Caching / revalidation control Framework-managed Full control (route handler revalidate export, or on-demand)

The deciding factor, in this case, was the combination of hreflang complexity and wanting a sitemap that's actually pleasant to open in a browser. Both are achievable with the built-in API, but a route handler makes the XML structure and the accompanying stylesheet explicit rather than inferred.

// app/sitemap.xml/route.ts
export const revalidate = 3600 // seconds

export async function GET() {
  const manifest = await loadRouteManifest()
  const entries = manifest.flatMap((entry) =>
    expandForLocales(entry, process.env.SITE_URL!)
  )

  const xml = renderSitemapXml(entries, {
    stylesheet: '/sitemap.xsl',
  })

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, max-age=0, s-maxage=3600',
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

The XSL stylesheet: a small, underrated detail

Anyone who has opened a raw sitemap.xml in a browser knows the experience: an unstyled wall of angle brackets that Chrome renders as a barely-readable tree. Browsers support attaching an XSLT stylesheet to XML documents via a processing instruction, which turns that same file into an actual HTML table — clickable links, priority and last-modified columns, locale badges — with zero JavaScript and zero extra requests beyond the stylesheet itself.

<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="/sitemap.xsl"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml">
  <url>
    <loc>https://example.com/en/blog/scaling-postgres</loc>
    <xhtml:link rel="alternate" hreflang="ar" href="https://example.com/ar/blog/scaling-postgres"/>
    <lastmod>2026-06-12</lastmod>
    <changefreq>monthly</changefreq>
    <priority>0.6</priority>
  </url>
</urlset>
Enter fullscreen mode Exit fullscreen mode

The stylesheet itself is served from its own route handler (app/sitemap.xsl/route.ts) as application/xslt+xml, and browsers apply it automatically when they load the XML document directly. Search engine crawlers ignore the stylesheet entirely and parse the raw XML — it's purely a debugging and QA convenience for humans, but it's the kind of detail that makes reviewing sitemap changes in a pull request preview actually usable instead of dreadful. See MDN's XSLT documentation for the underlying mechanism.

Keeping the admin console out of the index

The other half of this problem is exclusion, not inclusion. A large Next.js app that hosts both a marketing site and an authenticated admin console under the same domain has to be deliberate about which routes are even eligible to appear in a sitemap or be indexed at all. In practice this means three overlapping layers of defense, not one:

  1. Route grouping. Admin routes live under a (admin) route group, which never exports a sitemapConfig, so the manifest generator excludes them structurally rather than by convention.
  2. generateMetadata sets robots: { index: false } on every admin page's metadata, independent of the sitemap.
  3. A robots.txt disallow rule for the admin path prefix, as defense in depth in case a page is ever linked externally before its metadata ships.
// app/[locale]/(admin)/layout.tsx
export async function generateMetadata() {
  return {
    robots: { index: false, follow: false },
  }
}
Enter fullscreen mode Exit fullscreen mode
# app/robots.txt
User-agent: *
Disallow: /*/dashboard/
Sitemap: https://example.com/sitemap.xml
Enter fullscreen mode Exit fullscreen mode

Relying on just one of these is how /dashboard/settings ends up in Google's index. noindex meta tags only work if the crawler is allowed to fetch the page in the first place, and robots.txt disallow rules alone don't remove a URL that's already indexed — they just stop future crawling of it. Google's own guidance on robots meta tags is worth reading closely on this exact interaction; it's more subtle than "add one directive and move on."

Common mistakes

  • Hardcoding the sitemap in one file that nobody updates when routes change. This is the root cause of almost every stale-sitemap problem; colocated metadata with a drift check is the direct fix.
  • Emitting only one locale's URLs. Half your content becomes invisible to search engines in other languages, even though the pages exist and are perfectly crawlable.
  • hreflang without reciprocity. If /en/x points to /ar/x but /ar/x doesn't point back, search engines are explicit that they may ignore the annotation entirely.
  • Using noindex and expecting it to remove already-indexed pages instantly. It stops future indexing; removal takes a crawl-and-recrawl cycle, sometimes accelerated by Search Console's removal tool.
  • Forgetting the 50,000 URL / 50MB per-sitemap limit. Large content sites need a sitemap index file that references multiple child sitemaps, not one gigantic file.
  • No lastmod, or a lastmod that's just "today" on every build. This actively wastes crawl budget — a lastmod that's always current is worse than no lastmod at all, because it signals constant change where there is none.
  • Treating priority as universally meaningful. Google has stated for years that it largely ignores the priority field for ranking purposes; it's still useful internally for relative ordering and for other crawlers, but don't design around the idea that 1.0 gets you ranked higher.

Lessons learned

Sitemap correctness is a process problem disguised as a technical one. The actual XML is trivial to generate; the hard part is making sure the metadata describing each page stays true as the app grows across quarters and multiple contributors. Three things made the biggest difference in practice:

  • Colocating metadata with the route turns "did we update the sitemap" into "did the file compile," which is a much harder thing to skip by accident.
  • A visible drift warning beats a silent gap. Nobody reads a changelog entry about sitemap coverage; everybody sees a build warning.
  • Locale handling has to be designed in from the start. Retrofitting hreflang into a sitemap that was built single-locale-first is more work than building it multilingual from day one, because every downstream assumption (URL shape, canonical tags, alternate link generation) has to be revisited.

Best practices

  • Keep sitemap metadata as close as possible to the route it describes — ideally in the same file.
  • Automate a build-time or CI check that flags public routes with no sitemap metadata, even if it's just a warning.
  • Generate one sitemap entry per locale, with full hreflang reciprocity including an x-default entry.
  • Exclude private/admin surfaces at the route-group level, the metadata level, and the robots.txt level — don't rely on any single layer.
  • Use a sitemap index file once you approach the 50,000 URL limit rather than waiting until you hit it.
  • Set lastmod from actual content update timestamps, not the build time.
  • Attach an XSL stylesheet if you want humans to be able to sanity-check the sitemap without piping it through a formatter.
  • Treat priority and changefreq as hints for your own internal consistency, not as ranking levers.

References


If you've built a sitemap system that automatically kept itself honest — or one that quietly rotted for a year before anyone noticed — I'd like to hear how it happened. What's the smallest process change that would have caught it earlier?

Top comments (0)