afaqrashid.com  ·  portfolio · 2026 ed.

Next.js hreflang: 4 Traps I Tested and the Fix

In Next.js, you set hreflang with alternates.languages in your metadata. The catch is where you set it. In a root layout, every page gets the same hreflang links, pointing to the language versions of the homepage.

I tested alternates.languages on a fresh Next.js 16.4.4 app with next build and read the tags each page shipped with. Here are the four traps I hit and the setup that fixed them.

1. Layout hreflang is copied to every page

This root layout looks reasonable:

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  alternates: {
    canonical: "./",
    languages: { "en-US": "/en-US", "de-DE": "/de-DE", "x-default": "/" },
  },
};

A page at /a with no metadata of its own shipped with this:

<link rel="canonical" href="https://example.com/a"/>
<link rel="alternate" hrefLang="en-US" href="https://example.com/en-US"/>
<link rel="alternate" hrefLang="de-DE" href="https://example.com/de-DE"/>
<link rel="alternate" hrefLang="x-default" href="https://example.com"/>

The canonical "./" resolved to the page itself, but the language paths didn't. Every page told Google its German version was the German homepage.

Google also requires that "Each language version must list itself as well as all other language versions". The /a page never lists /a, so the set is broken from the start.

2. Setting a canonical on a page removes all hreflang

A page that set only its canonical:

export const metadata = { alternates: { canonical: "/b" } };

shipped with one tag and no hreflang at all:

<link rel="canonical" href="https://example.com/b"/>

Metadata merges shallowly. The page's alternates object replaced the layout's whole alternates object, languages included.

3. Setting languages on a page removes the canonical

The reverse is true too. A page that set only languages lost the layout's canonical:

<link rel="alternate" hrefLang="en-US" href="https://example.com/en-US/c"/>
<link rel="alternate" hrefLang="de-DE" href="https://example.com/de-DE/c"/>

No canonical tag, and no x-default. Whenever a page sets alternates, it has to set canonical and languages together.

4. Without metadataBase, the URLs are relative

I removed metadataBase and rebuilt. The build gave no warning, and the tags came out as paths:

<link rel="canonical" href="/a"/>
<link rel="alternate" hrefLang="en-US" href="/en-US"/>
<link rel="alternate" hrefLang="de-DE" href="/de-DE"/>
<link rel="alternate" hrefLang="en-UK" href="/en-UK"/>

Google says "Alternate URLs must be fully-qualified, including the transport method (http/https)". Set metadataBase in the root layout so every hreflang URL is absolute.

That build also shows a code problem Next.js won't catch. I used en-UK, and it went out unchanged. Google supports only ISO 639-1 language codes and ISO 3166-1 Alpha 2 region codes. The region code for the United Kingdom is GB, so the right value is en-GB.

The fix: build alternates per page

Remove languages from the root layout. Build canonical and languages together in each localized page from one helper, so they can't drift apart:

// app/[lang]/about/page.tsx
import type { Metadata } from "next";

const LOCALES = ["en", "de"] as const;

function alternates(lang: string, path: string): Metadata["alternates"] {
  return {
    canonical: `/${lang}${path}`,
    languages: {
      ...Object.fromEntries(LOCALES.map((l) => [l, `/${l}${path}`])),
      "x-default": `/en${path}`,
    },
  };
}

export function generateStaticParams() {
  return LOCALES.map((lang) => ({ lang }));
}

export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
  const { lang } = await params;
  return { alternates: alternates(lang, "/about") };
}

With metadataBase set in the root layout, both versions came out complete:

<!-- /de/about -->
<link rel="canonical" href="https://example.com/de/about"/>
<link rel="alternate" hrefLang="en" href="https://example.com/en/about"/>
<link rel="alternate" hrefLang="de" href="https://example.com/de/about"/>
<link rel="alternate" hrefLang="x-default" href="https://example.com/en/about"/>

Each page lists itself and the other version, and /en/about lists the same set. That matters because Google says "If two pages don't both point to each other, the tags will be ignored."

In a real app, move the helper to a shared file and pass each page's path. x-default is for visitors whose language matches none of your versions. Point it at your main language or a language picker.

Tags or sitemap, not both by accident

Google accepts hreflang in HTML tags, HTTP headers or the sitemap, and you only need one. Next.js sitemap.ts can output alternates too. If you use both, they have to agree, so pick one source and generate it from the same locale list.

Check your own pages

Open an inner page, view its source and search for hreflang. If the links point to your homepage's language versions, or the page has hreflang but no canonical, you've hit one of the traps above.

The free metadata checker below reads the canonical on any page you paste in, and the canonical post covers the rest of the canonical traps.

Related

Sources