Skip to content

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.

4 min read

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 /pricing both 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 serves https://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 page

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

Back to all articles