Canonicals and Sitemaps That Cannot Drift
Your sitemap lists routes your router no longer serves, and your canonical tags disagree with both. The fix is structural — derive all three from one source.
Three things describe your site's URLs to a crawler: the routes your application actually serves, the canonical tag on each page, and your sitemap. In most codebases these are maintained in three different places, by three different people, at three different times.
They drift. They always drift.
What drift looks like
- The sitemap lists
/blog/old-post, which 404s. /pricing/and/pricingboth return 200 with no canonical between them.- The German page's canonical points at the English URL, because someone copied the metadata block.
- A new route shipped three months ago and is in no sitemap at all.
- The sitemap has
https://www.example.com/...while the site serveshttps://example.com/....
None of these is dramatic. Together they mean the crawler spends its budget on URLs you do not care about and splits ranking signals across duplicates.
One source, three outputs
The structural fix: make the URL a computed value, and never write one by hand.
// site.config.ts — the only place a URL is constructed
export function absoluteUrl(path = '/'): string {
return `${siteConfig.url}${path.startsWith('/') ? path : `/${path}`}`;
}
/**
* Locale-aware pathname. `en` is the default locale and is served without a
* prefix, so its canonical URLs stay at the site root.
*/
export function localizedPath(locale: Locale, path = '/'): string {
const clean = path === '/' ? '' : path.startsWith('/') ? path : `/${path}`;
return locale === defaultLocale ? clean || '/' : `/${locale}${clean}`;
}
export function localizedUrl(locale: Locale, path = '/'): string {
return absoluteUrl(localizedPath(locale, path));
}Every page's canonical, every sitemap entry and every hreflang alternate calls
localizedUrl. Change the domain in one constant and all three follow. There is
no opportunity for a trailing slash to appear in one output and not another,
because the string is built in exactly one function.
Make the canonical impossible to omit
A page should not be able to ship without a canonical. The way to enforce that is to route metadata through a helper whose required arguments include everything a canonical needs:
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = await params;
const doc = getDoc('blog', locale, slug);
if (!doc) return {};
return buildMetadata({
locale,
title: doc.title,
description: doc.description,
path: `/blog/${slug}`, // canonical is derived from this
availableLocales: doc.availableLocales,
type: 'article',
});
}A developer adding a route writes path because the function will not
typecheck without it. They cannot forget the canonical, and they cannot get its
shape wrong, because they never write a URL.
The sitemap reads the same source the pages do
Next.js generates sitemap.xml from app/sitemap.ts. The mistake is to write
a static array there.
export default function sitemap(): MetadataRoute.Sitemap {
const entries: MetadataRoute.Sitemap = [];
for (const collection of ['services', 'blog'] as const) {
for (const locale of locales) {
// Same helper the pages render from — the two cannot disagree.
for (const doc of getDocs(collection, locale)) {
const path = `/${collection}/${doc.slug}`;
entries.push({
url: localizedUrl(locale, path),
lastModified: doc.updated ? new Date(doc.updated) : new Date(doc.date),
alternates: alternatesFor(path, availableLocalesFor(collection, doc.slug)),
});
}
}
}
return entries;
}Unpublish an article and it leaves the sitemap on the next build. Add a translation and the alternates grow. No maintenance step, because the sitemap is not a document — it is a projection.
lastModified is a claim, not a formality
Setting lastModified to new Date() for every entry tells the crawler your
entire site changed today, every day. After a few cycles it stops believing
you. Use the document's real modification date.
Trailing slashes: pick one and mean it
Next.js serves without a trailing slash by default. That is fine. What is not fine is serving both.
// next.config.ts
const nextConfig: NextConfig = {
trailingSlash: false, // the default; state it so nobody 'fixes' it later
};Then confirm the other form redirects rather than resolving:
curl -sI https://example.com/pricing/ | head -1
# HTTP/2 308 ← correct
# HTTP/2 200 ← two URLs for one pageThe same check applies to www versus apex, and to http versus https.
Each should be a single permanent redirect, not a second live copy.
Robots: do not index your previews
Every branch deployment is a complete copy of your site on a public URL. If it is indexable, it is a duplicate of your production site with a different hostname.
export default function robots(): MetadataRoute.Robots {
// Preview and branch deployments must never be indexed.
const isProduction =
process.env.VERCEL_ENV === 'production' ||
process.env.NEXT_PUBLIC_SITE_URL === siteConfig.url;
if (!isProduction) {
return { rules: [{ userAgent: '*', disallow: '/' }] };
}
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/api/'] }],
sitemap: absoluteUrl('/sitemap.xml'),
host: siteConfig.url,
};
}Note that this also fixes metadataBase. A preview deployment that inherits the
production URL will emit production canonicals from a staging host — which is
harmless — but one that emits its own hostname in canonicals while being
indexable is actively damaging.
A five-minute verification
# Canonical, on the page the crawler sees — not the inspector's hydrated DOM
curl -s https://example.com/de/blog/some-post | grep -E 'canonical|alternate'
# Sitemap parses, and the count matches what you expect
curl -s https://example.com/sitemap.xml | grep -c '<loc>'
# Every URL in the sitemap returns 200
curl -s https://example.com/sitemap.xml \
| grep -oP '(?<=<loc>)[^<]+' \
| xargs -P8 -I{} sh -c 'printf "%s %s\n" "$(curl -o /dev/null -sw "%{http_code}" "{}")" "{}"' \
| grep -v '^200'That last command should print nothing. If it prints anything, your sitemap is sending crawlers to pages that do not exist — and it will keep doing so until something structural changes about how the sitemap is produced.
The structural change is the whole point. Correct SEO is not a list of tags to remember. It is an architecture in which the wrong tag cannot be written.
