# 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.

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:

```ts
// 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:

```ts
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:

```ts
// 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:

```ts
// 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

```bash
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
