Skip to main content

Pattern · TypeScript

Dynamic sitemap.xml in Next.js (app/sitemap.ts)

One sitemap with static and content URLs; aligned with collectIndexableUrls and canonical URLs.

Level: intermediateTime estimate: ~40 min

The sitemap generator collects stable paths and slugs from CMS/files; lastmod comes from content dates when available.

  • Every sitemap URL must match the canonical
  • do not include noindex pages
  • for large catalogs use splitting or a sitemap index

app/sitemap.ts in the App Router returns an array of URLs. In one pass collect static routes and dynamic slugs (blog, library, case studies).

Code

Below is the actual logic from this project’s app/sitemap.ts (static paths, cases, posts, library patterns):

import type { MetadataRoute } from 'next'
import { caseSlugOrder } from '@/data/cases'
import { SITE_STATIC_PATHS } from '@/data/site-static-paths'
import { getAllPosts, getPostModifiedDate } from '@/lib/blog'
import { getAllLibraryEntries, getLibraryModifiedDate } from '@/lib/library'
import { getStaticPagesLastModified } from '@/lib/site-static-lastmod'
import { getSiteUrl } from '@/lib/site'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const site = getSiteUrl()
  const base = site.replace(/\/$/, '')
  const staticLastMod = getStaticPagesLastModified()
  const posts = await getAllPosts()
  const library = await getAllLibraryEntries()

  const entries: MetadataRoute.Sitemap = [
    ...SITE_STATIC_PATHS.map((path) => ({
      url: `${base}${path}`,
      lastModified: staticLastMod,
      changeFrequency: 'weekly' as const,
      priority: path === '/' ? 1 : 0.7,
    })),
    ...caseSlugOrder.map((slug) => ({
      url: `${base}/kejsy/${slug}/`,
      lastModified: staticLastMod,
      changeFrequency: 'monthly' as const,
      priority: 0.65,
    })),
    ...posts.map((p) => ({
      url: `${base}/blog/${p.slug}/`,
      lastModified: new Date(getPostModifiedDate(p)),
      changeFrequency: 'monthly' as const,
      priority: 0.6,
    })),
    ...library.map((e) => ({
      url: `${base}/biblioteka/${e.slug}/`,
      lastModified: new Date(getLibraryModifiedDate(e)),
      changeFrequency: 'monthly' as const,
      priority: 0.62,
    })),
  ]
  return entries
}

Verification

What to check:

  1. The file loads. Open https://your-domain/sitemap.xml — you should get XML, not an HTML 404. If you see the Next app shell, ensure app/sitemap.ts (or .js) exists and exports a default function.

  2. Search Console. In Google Search Console → Sitemaps, submit https://your-domain/sitemap.xml. “Couldn’t fetch” or parse errors mean the bot cannot reach valid XML.

  3. Canonical match. Pick several URLs from the file, open the pages, find <link rel="canonical" ...>. It must match the sitemap URL (including a trailing / if that is your canonical policy).

  4. Noindex consistency. Pages with robots: { index: false } or <meta name="robots" content="noindex"> should usually not be listed — otherwise you send conflicting signals.

Sources

Need this implemented for your domain and stack?

Short form: name, phone, and site. After you submit, we reply with next steps and a phase outline; details are refined on a call.