Command Palette

Search for a command to run...

How to Add Hreflang in Next.js (App Router)

How to Add Hreflang in Next.js (App Router)

T
Toolz Team
|Aug 23, 2026|13 min read

Part of the SEO Tools collection

Toolz runs on the Next.js App Router in nine languages, and the hreflang setup went through three wrong versions before it went right. The first put a module-scope alternates object in the layout, which cannot vary by locale, so every translated page announced the English URL as its canonical. That is the bug that deletes translations: it tells Google /es/pricing is a duplicate of /pricing and should not be indexed, which quietly undoes the translation work while every page still renders correctly.

The second version fixed the canonical and advertised every configured locale as an alternate, including locales whose translations had not been generated yet. So the site published a hreflang cluster naming pages that were English text under a Spanish URL. The third version, the one running now, derives the alternate set from actual translation coverage, per page.

This guide walks through the working setup: the locale config, the generateMetadata implementation, the sitemap variant, and the three mistakes above so you can skip them. It sits under the complete hreflang guide alongside the WordPress version.

TL;DR: In the App Router, hreflang comes from alternates.languages returned by generateMetadata, and the canonical must be built from the active locale rather than declared once at module scope. Build both from one locale map, emit the full reciprocal set on every page in the group, and include x-default. Only advertise locales whose content genuinely exists, or you publish a cluster pointing at untranslated pages. The hreflang generator is useful for checking the rendered output against a validated reference set.

What does Next.js give you out of the box?

The Metadata API supports hreflang directly. Returning alternates.languages from generateMetadata produces the <link rel="alternate" hreflang> tags in the head:

export async function generateMetadata({ params }) {
  const { locale } = await params
  return {
    alternates: {
      canonical: 'https://example.com/de/preise',
      languages: {
        'en-US': 'https://example.com/pricing',
        'de-DE': 'https://example.com/de/preise',
        'x-default': 'https://example.com/pricing',
      },
    },
  }
}

Next.js renders those into the head and handles the x-default key without special casing, as its Metadata API reference documents. What it does not do is decide which locales belong in the set, keep the canonical in sync, or stop you putting the whole object at module scope where it cannot vary. Those are the parts you have to get right, and they are the parts that break.

Note also that metadataBase affects relative URLs here. Hreflang requires absolute URLs, so either set metadataBase and use paths, or build absolute URLs yourself. I prefer building them explicitly from a configured origin, because a preview deploy that emits production URLs is its own category of problem.

Step 1: one locale map, and only one

Everything downstream reads from this. A locale needs three facts: the route segment, the BCP 47 tag for hreflang and <html lang>, and whether it is currently shipping.

// i18n/locales.ts
export const DEFAULT_LOCALE = 'en'

export const LOCALES = [
  { code: 'en', hreflang: 'en-US', label: 'English' },
  { code: 'de', hreflang: 'de-DE', label: 'Deutsch' },
  { code: 'fr', hreflang: 'fr-FR', label: 'Français' },
  { code: 'ja', hreflang: 'ja-JP', label: '日本語' },
]

/** '/pricing' -> '/de/pricing', and '/pricing' for the default locale. */
export function localize(path: string, locale: string): string {
  return locale === DEFAULT_LOCALE ? path : `/${locale}${path}`
}

The code and the hreflang value are deliberately separate fields. The route segment is de because nobody wants /de-DE/pricing in their URLs, and the hreflang value is de-DE because that is what you want to announce. Conflating them means either ugly URLs or a bare de in the annotation, and the moment you add pt-BR alongside pt-PT the conflation stops working at all.

Step 2: a helper that builds the set

One function, used by every page, so the shape cannot drift between routes:

export function hreflangAlternates(path: string, locales: string[], base = SITE_URL) {
  const eligible = LOCALES.filter((l) => locales.includes(l.code))
  // A single-entry cluster is not a cluster: emit nothing rather than
  // advertise a one-sided relationship.
  if (eligible.length < 2) return []

  const alts = eligible.map((l) => ({
    hreflang: l.hreflang,
    href: base + localize(path, l.code),
  }))
  alts.push({ hreflang: 'x-default', href: base + localize(path, DEFAULT_LOCALE) })
  return alts
}

Two decisions in there are worth stealing.

The early return on fewer than two locales. A page that exists only in English should emit no hreflang at all. A single self-referential annotation is not wrong exactly, but it is noise, and the version of this code that emitted it made every English-only page look like a broken cluster in crawl reports.

x-default is appended by the helper, not by the caller. Every implementation I have seen that leaves the fallback to the caller ends up with one template that forgot it. Fold it into the thing that builds the set and it cannot be forgotten.

Step 3: wire it into generateMetadata

// app/[locale]/pricing/page.tsx
import { localize, hreflangAlternates, SHIPPING_LOCALES } from '@/i18n/locales'
import { SITE_URL } from '@/lib/site'

export async function generateMetadata({ params }) {
  const { locale } = await params
  const path = '/pricing'

  const alternates = hreflangAlternates(path, SHIPPING_LOCALES)

  return {
    alternates: {
      // Built from the ACTIVE locale. This is the line that matters.
      canonical: `${SITE_URL}${localize(path, locale)}`,
      ...(alternates.length
        ? { languages: Object.fromEntries(alternates.map((a) => [a.hreflang, a.href])) }
        : {}),
    },
  }
}

The canonical is the line to stare at. It must be a function of locale, which means it cannot live in a module-scope constant, cannot live in the root layout, and cannot be shared across the locale segment. Every page in the group ends up with an identical languages map and a canonical unique to itself, which is exactly the shape hreflang and canonical require.

Because the map is identical across the group, reciprocity is satisfied structurally rather than by discipline. The German page lists German, English, French, Japanese, and x-default; so does the English page. There is no per-page logic that could omit an entry.

Step 4: only advertise what exists

This is the step most guides skip, and it is the one that produced the worst version of our setup.

If your translations are generated asynchronously, or a locale is half-populated, the configured locale list and the translated content are different sets. Advertising the configured list means publishing hreflang for pages that are English text under a localized URL. Google follows the annotation, finds English where German was promised, and you have manufactured duplicate content across nine locales at once.

The fix is to pass real coverage rather than the config:

// Coverage is per-page, not global: this page might be translated
// into three languages while the one next to it has none.
const locales = await localesForPage(path)
const alternates = hreflangAlternates(path, locales)

Pair that with robots: { index: false, follow: true } on pages that exist in a locale but have no translated copy yet. They stay reachable for anyone who arrives from your own navigation, and they stay out of the index until the copy lands. Both drop away automatically once coverage fills in, with no follow-up deploy.

Step 5: the sitemap variant

If you would rather keep hreflang out of page heads, the same helper feeds a sitemap route. One <loc> per piece of content with alternates as children, rather than one <loc> per locale:

// app/sitemap-pages.xml/route.ts
const entries = PAGES.map((path) => {
  const alts = hreflangAlternates(path, SHIPPING_LOCALES)
  const links = alts
    .map((a) => `\n    <xhtml:link rel="alternate" hreflang="${a.hreflang}" href="${a.href}"/>`)
    .join('')
  return `  <url>\n    <loc>${SITE_URL}${path}</loc>${links}\n  </url>`
})

const xml =
  `<?xml version="1.0" encoding="UTF-8"?>\n` +
  `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" ` +
  `xmlns:xhtml="http://www.w3.org/1999/xhtml">\n${entries.join('\n')}\n</urlset>`

The xmlns:xhtml declaration on <urlset> is required and easy to forget; without it the alternate entries are ignored. Note the structure: listing one <loc> per locale multiplies the file by the locale count and puts every translation into competition with its own canonical. Same discovery coverage, a fraction of the entries.

Pick one method. If you emit both head tags and sitemap entries for the same URLs, you have two sources that will drift. Ours are head tags for pages and sitemap alternates for the tool catalog, which are disjoint sets.

Does the default locale get a URL prefix?

This is a routing decision that changes every URL in your hreflang set, so make it before you write the helper rather than after.

Two strategies are common. As-needed prefixing serves the default locale unprefixed at /pricing and everything else at /de/pricing. Always prefixing serves every locale under a segment, including /en/pricing, with /pricing redirecting. Next.js middleware supports both, and next-intl exposes it as a localePrefix setting.

As-needed (/pricing, /de/pricing) Always (/en/pricing, /de/pricing)
Existing English URLs Preserved Every one becomes a redirect
hreflang for the default locale Points at the unprefixed URL Points at /en/...
x-default target The unprefixed root, which is also the en URL Must choose one prefixed locale
Symmetry in code localize() needs a default-locale branch No branch; every locale is uniform

I use as-needed prefixing on an existing site, because rewriting every indexed English URL to gain symmetry is a large cost for a small tidiness win, and redirects on your highest-traffic pages are not free. On a greenfield build, always-prefixing is cleaner: the localize helper loses its special case, and there is never a question about whether /pricing and /en/pricing are the same page.

What matters for hreflang either way is consistency. The URL in the annotation, the URL in the canonical, and the URL that serves a 200 without redirecting must be the same string. The one combination that reliably breaks is always-prefixing with hreflang still pointing at the unprefixed URLs: every entry for the default locale then names a redirect, and the return tag lives on the destination rather than the URL you named.

Which library should you use?

Most App Router i18n setups end up on next-intl or next-i18next, and neither generates hreflang for you. They solve routing and message loading; the annotations are still yours to emit. That is fine, because the helper above is twenty lines and you want it under your own control anyway.

Concern Handled by the library Yours
Locale routing and middleware Yes
Message catalogs and fallback Yes
<html lang> Usually Verify it matches the hreflang value
alternates.canonical per locale No Build from the active locale
alternates.languages No Build from the locale map
x-default No Append in the helper

The one library-specific thing to check is <html lang>. Structured data and hreflang that contradict the document language are worse than none, and a layout that hardcodes lang="en" while serving German is a surprisingly common leftover.

How do you verify it?

Build and read the actual output, because the template is not the evidence:

next build && next start
curl -s http://localhost:3000/de/preise | grep -E 'rel="(canonical|alternate)"'

Three checks on that output. The canonical is the URL you fetched. There is exactly one entry per locale plus one x-default. Every href is absolute and matches the URL that page is served at, trailing slash included.

Then fetch a sibling and diff the two languages blocks. They should be byte-identical; only the canonical differs. If they are not identical, something in your page is building the set from the current locale rather than from the group, which is the reciprocity failure in its most common disguise.

Finally, paste the set into the hreflang tag generator to validate the codes themselves. It checks each value against the ISO shape, flags unrecognized subtags, catches duplicates, and warns when a set has no fallback. It runs client-side, so a staging URL structure stays private. The tool guide walks through the workflow, and 12 common hreflang errors covers what to look for once the markup is live.

Frequently asked questions

How do I add hreflang tags in the Next.js App Router?

Return an alternates.languages object from generateMetadata, mapping each BCP 47 tag to its absolute URL, and set alternates.canonical from the active locale. Next.js renders both into the head.

Why must the canonical be built inside generateMetadata?

Because it has to vary by locale. A module-scope canonical cannot, so every translated page would declare the default-locale URL as canonical, which removes the translation from the index.

Does next-intl generate hreflang tags automatically?

No. next-intl handles routing and message loading. The hreflang annotations and the per-locale canonical are still yours to emit from generateMetadata.

How do I add x-default in Next.js?

Add an 'x-default' key to the alternates.languages object pointing at your fallback URL. Next.js passes the key through without special handling. Append it inside the helper that builds the set so no template can forget it.

Should hreflang go in the head or the sitemap in Next.js?

Either works, but not both for the same URLs. Head tags via generateMetadata are easier to debug. A sitemap route suits large catalogs and keeps page heads lean; remember the xmlns:xhtml declaration on <urlset>.

What if a locale is only partly translated?

Advertise only the locales whose content genuinely exists for that page, and mark the untranslated pages noindex, follow. Advertising the configured locale list publishes a cluster pointing at untranslated pages.

Do hreflang URLs in Next.js need to be absolute?

Yes. Hreflang requires fully qualified URLs. Either set metadataBase or build the URLs from a configured origin so preview deploys do not emit production URLs.

How do I check the rendered hreflang output?

Run a production build, fetch two sibling pages, and compare. The languages blocks should be byte-identical across the group while each canonical points at its own URL.


Comments

0 comments

0/2000 characters

No comments yet. Be the first to share your thoughts!