Skip to content

A sitemap and canonical URLs that do not fight each other

lastModified from new Date() teaches crawlers to ignore your dates, and a canonical built from request headers points at your preview deploys.

MDX blog3 min readships at docs/solutions/blog-mdx/sitemap-and-canonical-urls.md

Tags: seo · sitemap · canonical · metadata · nextjs

Two SEO bugs show up in almost every Next.js blog. Both are one line of code, both look correct in review, and neither produces an error anywhere.

Bug 1: lastModified: new Date()

Here is the sitemap almost everyone writes first:

// src/app/sitemap.ts: wrong
export default function sitemap(): MetadataRoute.Sitemap {
  return allPosts().map((post) => ({
    url: `https://example.com/blog/${post.slug}`,
    lastModified: new Date(),
  }));
}

Every build stamps every URL with the build time. Deploy a CSS tweak on Tuesday and your sitemap tells Google that all 120 posts changed on Tuesday.

Crawlers treat lastmod as a hint and measure whether the hint is true. A site that claims everything changed and then serves byte-identical pages gets its lastmod values discounted, and the signal you actually wanted, "this one post was rewritten, come and look", stops working for the post that needed it.

It also breaks your own tooling: a sitemap diff is noise on every deploy, so nobody reads it.

The value has to come from the content:

export default function sitemap(): MetadataRoute.Sitemap {
  const site = appUrl();
  return publishedPosts().map((post) => ({
    url: `${site}/blog/${post.slug}`,
    lastModified: post.updated ?? post.date, // frontmatter, not the clock
    changeFrequency: "monthly",
    priority: 0.6,
  }));
}

updated ?? date gives you the honest answer: the day the post was published, unless a human decided the post changed enough to say so. Adding an updated field to frontmatter for a typo fix is dishonest in the same way new Date() is, just slower.

Two things worth knowing while you are in there:

  • priority is almost entirely ignored by Google. Set it if you like, but do not spend an afternoon tuning it.
  • changeFrequency is also a hint, not a schedule. It does not make anything get crawled more often.

Bug 2: canonical URLs built from the request

The other classic:

// wrong
const host = (await headers()).get("host");
export const metadata = {
  alternates: { canonical: `https://${host}/blog/${slug}` },
};

This is worse than it looks. Every preview deployment now serves a canonical tag pointing at itself. If a preview URL is ever crawled (they leak through pull request comments, Slack unfurls and shared links more often than people expect) you have published a page that declares a preview host as the canonical home of your content.

Reading headers() also opts the route out of static rendering, so you traded your prerendered post page for a dynamic one to compute a constant.

The canonical URL of a page is a fact about your site, not about the request that arrived. It comes from configuration:

// src/app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL(appUrl()),   // NEXT_PUBLIC_APP_URL
  title: { default: "My Site", template: "%s, My Site" },
};

// src/app/blog/[slug]/page.tsx
export async function generateMetadata(props: PageProps<"/blog/[slug]">): Promise<Metadata> {
  const { slug } = await props.params;
  const post = getPost(slug);
  if (!post) return {};
  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: `/blog/${post.slug}` },
  };
}

With metadataBase set once in the root layout, every relative canonical, every OG URL and every image path resolves against the right origin. Set NEXT_PUBLIC_APP_URL to the production domain in your production environment and to http://localhost:3000 locally, and the problem disappears at the source.

Keep the two consistent

A sitemap and a canonical tag that disagree is its own bug class. Make sure:

  • Trailing slashes match. /blog/post in the sitemap and /blog/post/ in the canonical are two URLs. Pick one shape and let nothing else generate URLs by hand: appUrl() strips the trailing slash exactly so that `${appUrl()}/blog` cannot produce a double one.
  • Drafts are in neither. A post with draft: true is excluded from the sitemap, the feed and the index. If it is reachable at a URL, it will be found.
  • Only canonical URLs are listed. No query strings, no tag-filtered duplicates of the same list, no ?page=2 variants that render the same content.
  • Redirected URLs are removed. If you replaced a slug and redirected the old one, the old URL does not belong in the sitemap; the redirect handles it.

Verifying

bun run build && bun run start
curl -s http://localhost:3000/sitemap.xml
curl -s http://localhost:3000/blog/hello-world | grep -i canonical

Check that:

  1. every <loc> uses your real origin, not localhost, when built for production;
  2. <lastmod> values differ between posts and match their frontmatter;
  3. the canonical link on a post equals the <loc> for that post, character for character;
  4. building twice produces an identical sitemap. If it does not, something is still reading a clock.

That last check is the one worth automating. A sitemap that is stable across builds is a sitemap whose dates mean something.