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:
priorityis almost entirely ignored by Google. Set it if you like, but do not spend an afternoon tuning it.changeFrequencyis 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/postin 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: trueis 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=2variants 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:
- every
<loc>uses your real origin, notlocalhost, when built for production; <lastmod>values differ between posts and match their frontmatter;- the canonical link on a post equals the
<loc>for that post, character for character; - 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.