hreflang, per-locale canonicals, and SEO
How to make search engines rank each localized page in its own language, using hreflang alternates, self-canonicals, sitemaps, and Open Graph locale signals.
A French user in Paris opens Google and types “logiciel de facturation”: billing software. Your SaaS has exactly what she wants. You shipped a translated /fr-FR/billing page over the last five lessons, with every label in French, prices in euros, and dates formatted the way she reads them. And yet the result Google shows her is the English /billing page. She lands on a wall of English, bounces in two seconds, and goes to a competitor. Three months later someone on your team looks at the analytics, concludes that the French translation isn’t converting, and starts questioning the whole localization effort.
The translation was never the problem. The problem is that nothing told Google the French page exists, or that it’s the French version of the English one. Search engines don’t guess this from the URL; you have to declare it. The declaration is a small, specific set of signals you attach to each page. Get them right and Google serves /fr-FR/billing to French searchers and /billing to English ones automatically, sharing the ranking authority both pages have earned. Get them wrong, or omit them, and your translated pages stay invisible in their own language no matter how good they are.
The hard part is that this entire surface is invisible during development. It doesn’t show up in local dev, it doesn’t break any test you’ve written, and it passes code review because the page renders fine. The only place it surfaces is in organic search traffic, months later, as a number that’s lower than it should be and impossible to attribute to a cause. That’s why this lesson treats it as a structural discipline rather than a feature: you build it correctly once, in a shape that’s hard to get wrong, because you won’t get fast feedback telling you it’s broken.
The reassuring part is that the work is small. You shipped the hard part already. The per-locale rendering came across the last five lessons, and the single-locale metadata surface came back in the chapter on Next.js metadata: generateMetadata, metadataBase, alternates.canonical, sitemap.ts, opengraph-image.tsx. This lesson is the bridge. It adds exactly one dimension, locale, to metadata you already know how to write. By the end you’ll have a small lib/seo.ts with two helpers, and you’ll know the shape of a localized generateMetadata, a localized sitemap.ts, and a per-locale OG image. More importantly, you’ll be able to read this surface in a pull request and catch the one bug that silently kills translated rankings.
Set one framing before anything else, because every section leans on it. This SEO surface is a marketing-route concern. Your public pages, the landing page, /pricing, and /features, are the search target, and they get the full treatment: hreflang, sitemap entries, locale-aware social cards. Your authenticated app, /dashboard and /settings, is a different world. Those pages are noindex, because you don’t want Google ranking a logged-out user’s view of a private dashboard. Locale resolution still runs there, since a French user still sees French UI inside the app, but the public SEO surface is dark. So as we go, every signal is either marketing-only or both, and I’ll say which.
Why translated pages don’t rank without help
Section titled “Why translated pages don’t rank without help”Before any Next.js syntax, you need a clear picture of what goes wrong by default, because the tags only make sense once you’ve seen the failure.
Picture two URLs: /billing in English and /fr-FR/billing in French. To you they’re obviously the same page in two languages. To Google, with no further information, they’re two unrelated documents that happen to be about the same thing. Google crawls and indexes both. But now it has a problem: when a French user searches for billing software, which of these two competing pages should it rank? It has no signal that they’re a pair, so it falls back on raw authority, and the English original almost always has more inbound links, more history, and more weight. So Google surfaces /billing. Worse, because the two pages are so similar in structure, Google may flag them as near-duplicates and suppress one entirely, splitting the ranking signals that should have reinforced each other.
The fix is to declare the relationship. There’s an annotation called hreflang , a name that fuses href and lang, whose entire job is to tell Google that these URLs are the same page in different languages and which is which. Once Google sees that declaration, everything changes. It treats the URLs as a cluster: one logical page with several language variants. It pools the ranking authority across the whole cluster instead of making the variants fight each other. And it serves the variant that matches each user’s own language and region signals. The French searcher gets /fr-FR/billing, the English searcher gets /billing, and both rank on the strength of the cluster as a whole.
The difference is worth seeing side by side.
That gap, the French searcher landing on English versus landing on French, is the whole stake of this lesson, measured in organic traffic you either capture or hand to a competitor.
So what actually installs that relationship? Four signals, which we’ll build in order. Three of them are the core, and they’re what the lesson is really about:
hreflangalternates: the tags that declare which URLs are language-siblings of each other.- A self-canonical: each variant declaring itself as the authoritative URL, not a duplicate of the English original.
- A sitemap entry: so the crawler reliably discovers every variant in the first place.
The fourth is smaller and lives off to the side. Open Graph locale signals carry the same declaration to social platforms, Facebook, LinkedIn, Slack, and X, when someone shares a link. We’ll get to it after the core three.
Start with hreflang, because the other two only make sense once you understand the cluster it creates.
hreflang: declaring a page’s language siblings
Section titled “hreflang: declaring a page’s language siblings”We’ll cover hreflang as a concept first, completely independent of Next.js, and that order matters. Next.js will eventually generate these tags for you from a metadata object, and it’s tempting to treat the framework field as the whole story. But Google validates hreflang against its own rules regardless of how the tags were produced; the framework only emits them. If you learn only the Next.js field, you’ll write code that compiles, renders, and is silently wrong in Google’s eyes. So learn the tag, learn the rules Google enforces, and then learn how the framework emits them.
Here’s what the raw tags look like in the <head> of the billing page. This is the exact HTML Next.js produces by the end of the section, and seeing the target first makes the framework code obvious later.
<link rel="alternate" hreflang="en-US" href="https://app.example.com/billing" /><link rel="alternate" hreflang="fr-FR" href="https://app.example.com/fr-FR/billing" /><link rel="alternate" hreflang="de-DE" href="https://app.example.com/de-DE/billing" /><link rel="alternate" hreflang="x-default" href="https://app.example.com/billing" />Each tag pairs a locale with the URL that serves that locale’s version of this page. Read together, they tell Google that this billing page exists in English, French, and German, at these three URLs. Simple enough on the surface. But there are three rules underneath, and each one has caught teams who thought they’d done it right. Each rule is easiest to understand as the failure it prevents.
Rule 1: every page lists itself
Section titled “Rule 1: every page lists itself”The failure is to put hreflang tags for the other languages on each page but leave off the page’s own entry. The French page lists English and German, but not French. It feels redundant for the French page to declare itself as French when you’re already on it. Google disagrees. A page that doesn’t include its own hreflang entry is treated as not part of the cluster at all, and the entire set of declarations on that page is ignored.
So the rule is that every page lists all the variants, including itself. The French billing page emits a fr-FR entry pointing at its own URL, right alongside the en-US and de-DE entries. If you ship three locales, every page carries three hreflang entries, plus x-default, which is coming up. Self-reference is mandatory on every page, with no exceptions.
Rule 2: the declarations must point both ways
Section titled “Rule 2: the declarations must point both ways”This failure is nastier because nothing tells you it happened. Say the French page correctly lists /billing as its English alternate, but the English page lists no French alternate, perhaps because it was built first, before French existed. You now have a one-sided declaration: French points to English, but English doesn’t point back. Google’s rule is that hreflang relationships must be bidirectional. If A claims B as an alternate, B must claim A in return. When the return link is missing, Google doesn’t warn you and doesn’t error; it silently discards the declaration as untrustworthy. Your French page reverts to competing with English on raw authority, exactly as if you’d written no tags at all.
Consider how dangerous this is. One-sided hreflang looks identical to correct hreflang in your code, in your rendered HTML, and in local dev. It produces no error anywhere. The only signal is the absence of the ranking benefit you expected, months later, in a metric you can’t easily trace. This is the strongest argument for the structural approach this lesson builds toward, because a hand-maintained list of alternates per page will drift and break silently. Someone adds a locale and updates four pages but forgets the fifth. Someone renames a route on the English page but not the German one. The bidirectionality breaks, nobody notices, and rankings quietly degrade. The only safe shape is a helper that generates the complete, symmetric set of alternates for every page from one source, so that “every page points to every sibling, both ways” is guaranteed by construction rather than by discipline.
Rule 3: x-default catches everyone else
Section titled “Rule 3: x-default catches everyone else”You ship English, French, and German. A Japanese speaker searches and lands in your cluster, but none of your three hreflang entries match their language. What does Google serve? Without guidance, it guesses. With guidance, you tell it: there’s a special pseudo-locale, hreflang="x-default", that declares the fallback URL for users whose language matches none of your alternates. It’s not a real locale, just a marker that means “everyone else.”
The sensible default, and the one this chapter uses, is to make your default-locale URL the x-default, so the Japanese searcher lands on /billing (English), your most-supported variant, rather than a random guess. Like the others, the x-default entry goes on every page in the cluster. Some sites instead point x-default at a neutral language-picker page that asks the user to choose; that’s a valid alternative, but a plain default-locale fallback is simpler and fine for most products.
A note on the tag format
Section titled “A note on the tag format”Throughout this lesson the tags use full locale tags, fr-FR rather than fr. There’s a real decision behind that, and it connects to the “locale is a contract” rule from the resolution-chain lesson. A locale tag in the BCP 47 format is language-REGION: a language subtag, optionally followed by a region subtag.
Google actually accepts a region-less language code: hreflang="fr" is valid and targets French speakers regardless of region. So why insist on fr-FR? For consistency. In this codebase the hreflang value, the URL prefix (/fr-FR/...), and the users.locale column all carry the same string, fr-FR, everywhere. That means one tag format, no mapping layer, and no place for fr and fr-FR to drift apart. It’s a codebase-hygiene decision, not a Google mandate, and worth flagging as such so you don’t tell people Google requires the region. It doesn’t.
Google does have one hard rule about the format, and it’s worth knowing because it’s a common mistake: the language code is mandatory, and a country code alone is invalid. hreflang="be" meaning “Belgium” is wrong, because be is the language code for Belarusian. hreflang="us" is meaningless, because there’s no us language. Google will not infer a language from a region. The language always comes first and is required; the region is the optional refinement.
One thing is explicitly out of scope: using region to target distinct audiences, with en-US versus en-GB versus en-AU as separate English variants for separate markets. That’s a real technique, but this chapter ships language-targeted only. It’s named here once so you know it exists; we won’t go further.
The rule most teams get wrong is the combination of self-reference and bidirectionality: every variant declares every sibling, both directions, including itself. It’s easier to see than to describe.
| Page declares alternate for → | en-US | fr-FR | de-DE |
|---|---|---|---|
/billing en-US | /billing declares en-US (self-reference) | /billing declares fr-FR | /billing declares de-DE |
/fr-FR/billing fr-FR | /fr-FR/billing declares en-US | /fr-FR/billing declares fr-FR (self-reference) | /fr-FR/billing declares de-DE |
/de-DE/billing de-DE | /de-DE/billing declares en-US | /de-DE/billing declares fr-FR | /de-DE/billing declares de-DE (self-reference) |
That fully-filled, symmetric grid is the target. Notice there’s no special-casing: the French page’s row looks just like the English page’s row, which looks just like the German one. That uniformity is the whole reason a single helper can generate every page’s tags, which is exactly what we’ll build next. But first, lock in the rules, because they’re the part that survives no matter which framework you use.
Three quick checks before we move to the framework. Each one is a real pull request that renders fine and ships green, and your job is to spot which ones Google silently ignores.
A teammate adds a German launch. They edit /de-DE/features to list /features as its en-US alternate, but they don’t touch /features itself — it still ships with no German entry. The build passes and both pages render correctly. What happens to the German page in a German SERP?
hreflang at all — Google drops the half-declared relationship.hreflang relationships must be bidirectional: if /de-DE/features claims /features as its English alternate, /features has to claim /de-DE/features back. The return link is missing, so Google treats the whole declaration as untrustworthy and discards it — with no error, ever. The correct fix is to add a de-DE entry to /features as well, which the generateAlternates helper does for you by emitting the same symmetric set on every page.You audit the rendered <head> of /fr-FR/pricing and find exactly two hreflang tags: one for en-US and one for de-DE. The French URL is reachable, fully translated, and listed correctly on the English and German pages. Will it rank as the French variant?
x-default tag pointing at it./fr-FR/pricing is missing its own fr-FR entry, so Google considers it outside the cluster and ignores the declarations it does carry — even though the siblings point at it correctly. The self-reference is what generateAlternates guarantees by iterating every configured locale, including the current one.Your site ships en-US, fr-FR, and de-DE, with English as the default locale. A user whose browser is set to Japanese searches and matches your cluster. Which choice describes the job of the x-default entry here?
/pricing, the English default./pricing above the translations.x-default is a fallback, not a priority signal. It tells Google which URL to serve a visitor whose language matches none of your alternates — here, the Japanese searcher. This chapter points it at the default-locale URL (/pricing), so unmatched users land on your most-supported variant rather than a guess. It does not rank or redirect anything.Emitting hreflang with alternates.languages
Section titled “Emitting hreflang with alternates.languages”Now the framework. Next.js builds the entire hreflang tag set, plus the canonical that we’ll cover next, from a single alternates object you return from generateMetadata. You describe the relationships once, as data, and Next.js emits the symmetric set of <link> tags. Here’s that object mapped onto the billing page.
// inside generateMetadata for app/[locale]/billing/page.tsxalternates: { canonical: '/fr-FR/billing', languages: { 'en-US': '/billing', 'fr-FR': '/fr-FR/billing', 'de-DE': '/de-DE/billing', 'x-default': '/billing', },},Next.js reads this single object and emits the <link rel="alternate" hreflang> tags plus the canonical link. You describe the relationships as data, and the framework produces the HTML.
// inside generateMetadata for app/[locale]/billing/page.tsxalternates: { canonical: '/fr-FR/billing', languages: { 'en-US': '/billing', 'fr-FR': '/fr-FR/billing', 'de-DE': '/de-DE/billing', 'x-default': '/billing', },},canonical is this page’s authoritative URL. Because this is the French page, its canonical is the French URL, not the English one. Hold that thought; it’s the entire next section.
// inside generateMetadata for app/[locale]/billing/page.tsxalternates: { canonical: '/fr-FR/billing', languages: { 'en-US': '/billing', 'fr-FR': '/fr-FR/billing', 'de-DE': '/de-DE/billing', 'x-default': '/billing', },},languages is one entry per locale. Note that fr-FR is present even though this is the French page. That’s the mandatory self-reference, satisfied automatically.
// inside generateMetadata for app/[locale]/billing/page.tsxalternates: { canonical: '/fr-FR/billing', languages: { 'en-US': '/billing', 'fr-FR': '/fr-FR/billing', 'de-DE': '/de-DE/billing', 'x-default': '/billing', },},x-default is a special key Next.js maps to hreflang="x-default". Here it points at the default-locale URL, the fallback for unmatched languages.
These are relative paths. metadataBase, the base URL you set in the metadata chapter, is what resolves them to the absolute URLs Google needs.
That object is correct for the French page. But consider what it would take to hand-write it for every page in every locale: the French, English, and German billing pages, then the same three for pricing, for features, and for the landing page. Each one needs all the alternates, its own URL as the canonical, and the self-reference and bidirectional links. Hand-maintain that and you’re one forgotten edit away from the silent breakage we just covered.
So you don’t hand-maintain it. You write it once, as a function, and call that function on every page. This is the structural defense, the part of the lesson that takes a senior’s perspective.
The helper lives in lib/seo.ts and builds the full languages map by mapping over your configured locales. The key move is that it constructs each locale’s URL with getPathname from the typed navigation module you set up in the next-intl wiring lesson, the same @/i18n/navigation you’ve been routing through. next-intl’s own docs point at getPathname as the intended tool for exactly this, building hreflang and canonical URLs. It’s the right tool rather than string concatenation for a subtle but important reason: getPathname already encodes your localePrefix: 'as-needed' setting. Default-locale URLs come back unprefixed (/billing) and the others come back prefixed (/fr-FR/billing), and that logic lives in one place, your routing config, instead of being re-derived, slightly differently, in your SEO helper.
import { routing } from '@/i18n/routing';import { getPathname } from '@/i18n/navigation';import type { Locale } from '@/lib/i18n';
export const generateAlternates = (locale: Locale, href: string) => { const languages = Object.fromEntries( routing.locales.map((l) => [l, getPathname({ locale: l, href })]), ); return { canonical: getPathname({ locale, href }), languages: { ...languages, 'x-default': getPathname({ locale: routing.defaultLocale, href }), }, };};Read what this guarantees. languages is built by iterating every configured locale, so the self-reference (rule 1) is automatic: the current locale is in routing.locales, so it’s in the map. Because every page calls this same function over the same locale list, every page emits the same symmetric set, so bidirectionality (rule 2) is structural: there’s no way for one page to claim a sibling that doesn’t claim it back. The x-default (rule 3) is appended from the default locale, on every page. All three rules that catch teams are satisfied by construction. A reviewer’s job shrinks from “are all the alternates correct and symmetric?” to one question: “does this page call generateAlternates?”
Notice the first argument. generateAlternates takes the current locale specifically so the canonical it returns is this page’s own URL, getPathname({ locale, href }) with the page’s own locale. That’s the mechanism that makes “canonical equals the localized URL” automatic rather than something you remember to do, and it’s the perfect setup for the next section, because getting that canonical wrong is the bug this whole lesson exists to prevent.
There’s one more thing to address here, because it trips up experienced developers specifically. You may have absorbed, somewhere, the advice that hreflang tags must be in the <head> or Google ignores them. That advice was true once and is now a trap, because of how Next.js renders metadata.
As of Next.js 15.2 and into 16, generateMetadata streams. When it resolves after the initial UI has already been sent, Next.js appends the metadata tags to the end of the <body> rather than the <head>, and that is by design, not a bug. Next.js verified that JavaScript-executing crawlers, Googlebot among them, inspect the fully-rendered DOM and read those tags correctly wherever they land. For bots that don’t run JavaScript, the HTML-limited crawlers like facebookexternalhit that scrape the raw response, Next.js detects them by User-Agent (the htmlLimitedBots list, overridable in next.config.ts) and serves them blocking metadata in the <head> instead. Both audiences are handled.
So the experienced-developer reflex needs recalibrating. Don’t see body-appended hreflang under streaming and “fix” it by disabling streaming, because you’d be slowing your pages down to solve a problem Next.js already solved. The reflex that actually matters is simpler: is this metadata coming from a framework metadata export on a Server Component? There’s a real warning sign here, and it’s not head-versus-body. metadata and generateMetadata are Server-Component-only, and a Client Component cannot export them at all. So if you ever see someone hand-injecting <link rel="alternate"> from inside a client component, that’s the bug: no streaming guarantee, no htmlLimitedBots handling, just raw tags sprayed into the DOM. The framework export on a Server Component is the correct path, and anything reaching around it is the thing to flag in review.
How do you verify the tags actually render correctly? Not by eyeballing it in dev, because dev won’t show you what a crawler sees. The durable defense is a CI check that fetches the rendered HTML for each locale and asserts the expected symmetric hreflang set is present. We’ll come back to this in the validation section; for now, just know that automated assertion is where this gets verified, not the browser.
The canonical is the localized URL, not the default
Section titled “The canonical is the localized URL, not the default”This is the section the lesson is built around. If you take one thing away, take this.
The rule is that each locale variant is its own canonical. The French page’s canonical is /fr-FR/billing. The English page’s canonical is /billing. The German page’s canonical is /de-DE/billing. Each variant declares itself as authoritative.
Now the bug, stated as plainly as possible. It’s extremely tempting to point every variant’s canonical at the “real” page, the English original: canonical: '/billing' on the French page, on the German page, on all of them. It feels right, because English is the source of truth and the translations are derivatives, so surely English is the canonical. This instinct is wrong, and the consequence is severe. A canonical tag means “this URL is the authoritative version, and the page you’re looking at is a duplicate of it.” So when the French page declares /billing as its canonical, you have told Google, in the clearest possible terms, that this French page is a duplicate of the English page, so don’t index it, don’t rank it, and treat it as a copy. Google obliges. Your French page, fully translated and perfectly correct, vanishes from French search results. Then someone concludes the translation isn’t working, and we’re back to the French user in Paris from the start of this lesson, except now you built the wall yourself.
Here’s the contrast in code.
// app/[locale]/billing/page.tsx — the fr-FR pagealternates: { canonical: '/billing', languages: generateAlternates(locale, '/billing').languages,},This deletes the French page from search. The canonical says “I’m a duplicate of /billing,” so Google dedupes the French variant away. It exists, it’s correct, and it never ranks in a French SERP .
// app/[locale]/billing/page.tsx — the fr-FR pagealternates: generateAlternates(locale, '/billing'),Each variant ranks in its own language. generateAlternates already sets canonical to this locale’s URL, so the French page is its own authority. hreflang handles the relationship to the other languages, and the canonical never collapses them.
Notice the “right” version is also less code. You just spread the helper’s full return value, canonical included, instead of overriding it with a hardcoded string. The structural helper from the last section makes the correct thing the easy thing: do nothing special and the canonical is already the localized URL. The bug requires you to actively reach in and break it.
It’s worth being precise about how canonical and hreflang divide the labor, because conflating them is what produces the bug:
- Canonical answers “what is the authoritative URL for this content in this language?” The answer is this page’s own localized URL.
hreflanganswers “where are the other-language versions of this content?” The answer is the sibling URLs.
They are not competitors and they are not redundant. They work as a pair: a self-canonical says “I am the real French page,” and the hreflang cluster says “and here are my English and German siblings.” Point the canonical at English and you’ve contradicted the hreflang, claiming at once that the French page is real (it’s in the cluster) and that it’s a duplicate (its canonical is English). Google resolves that contradiction by believing the canonical and dropping the page.
If this feels like a brand-new rule, it isn’t. It’s the i18n version of something you already know. In the Next.js metadata chapter, alternates.canonical was the tool for the single-locale case: a page’s canonical is its own clean URL, with tracking query params and the like stripped off, so /billing?ref=twitter doesn’t fragment into a hundred near-duplicates. The rule was always “a page’s canonical is its own URL.” All that’s changed is what “its own URL” means once you have locales: it now means its own localized URL. Same rule, one more dimension.
Per-locale sitemaps with MetadataRoute.Sitemap
Section titled “Per-locale sitemaps with MetadataRoute.Sitemap”hreflang and canonicals tell Google how the variants relate. The sitemap makes sure Google finds them in the first place. You met sitemap.ts returning a MetadataRoute.Sitemap in the metadata chapter, for the single-locale case: a typed list of your URLs with their last-modified dates. The i18n delta is small: each URL entry can carry its own alternates.languages map, and Next.js emits the sibling URLs as <xhtml:link rel="alternate" hreflang> children inside that <url> entry.
That’s worth pausing on. It means the sitemap can carry the same hreflang information as the head tags, and for large sites it’s often the preferred home, because all the alternates live in one crawlable file the search engine fetches once, rather than being discovered tag-by-tag across hundreds of individually-fetched pages.
One difference from generateMetadata to keep straight: the sitemap file has no metadataBase to lean on, so its URLs must be absolute, and you prepend the base URL yourself. But you still build the path with the same getPathname, so the prefix logic stays single-sourced and your sitemap can never disagree with your head tags about where a locale lives.
import type { MetadataRoute } from 'next';import { routing } from '@/i18n/routing';import { getPathname } from '@/i18n/navigation';
const BASE = 'https://app.example.com';const MARKETING_PATHS = ['/', '/pricing', '/features'];
const abs = (locale: string, href: string) => `${BASE}${getPathname({ locale, href })}`;
export default function sitemap(): MetadataRoute.Sitemap { return MARKETING_PATHS.flatMap((href) => routing.locales.map((locale) => ({ url: abs(locale, href), lastModified: new Date(), alternates: { languages: Object.fromEntries( routing.locales.map((l) => [l, abs(l, href)]), ), }, })), );}Marketing routes only. The authenticated app is noindex, so it’s deliberately absent here. This is the dichotomy from the intro, made concrete in one constant.
import type { MetadataRoute } from 'next';import { routing } from '@/i18n/routing';import { getPathname } from '@/i18n/navigation';
const BASE = 'https://app.example.com';const MARKETING_PATHS = ['/', '/pricing', '/features'];
const abs = (locale: string, href: string) => `${BASE}${getPathname({ locale, href })}`;
export default function sitemap(): MetadataRoute.Sitemap { return MARKETING_PATHS.flatMap((href) => routing.locales.map((locale) => ({ url: abs(locale, href), lastModified: new Date(), alternates: { languages: Object.fromEntries( routing.locales.map((l) => [l, abs(l, href)]), ), }, })), );}The cross-product: one <url> entry per locale per path. Three paths times three locales is nine entries, each a first-class page.
import type { MetadataRoute } from 'next';import { routing } from '@/i18n/routing';import { getPathname } from '@/i18n/navigation';
const BASE = 'https://app.example.com';const MARKETING_PATHS = ['/', '/pricing', '/features'];
const abs = (locale: string, href: string) => `${BASE}${getPathname({ locale, href })}`;
export default function sitemap(): MetadataRoute.Sitemap { return MARKETING_PATHS.flatMap((href) => routing.locales.map((locale) => ({ url: abs(locale, href), lastModified: new Date(), alternates: { languages: Object.fromEntries( routing.locales.map((l) => [l, abs(l, href)]), ), }, })), );}abs wraps getPathname with the base URL. It draws on the same prefix source of truth generateAlternates uses, so the sitemap and the head tags can’t drift apart.
import type { MetadataRoute } from 'next';import { routing } from '@/i18n/routing';import { getPathname } from '@/i18n/navigation';
const BASE = 'https://app.example.com';const MARKETING_PATHS = ['/', '/pricing', '/features'];
const abs = (locale: string, href: string) => `${BASE}${getPathname({ locale, href })}`;
export default function sitemap(): MetadataRoute.Sitemap { return MARKETING_PATHS.flatMap((href) => routing.locales.map((locale) => ({ url: abs(locale, href), lastModified: new Date(), alternates: { languages: Object.fromEntries( routing.locales.map((l) => [l, abs(l, href)]), ), }, })), );}Each entry’s own sibling map. Next.js emits these as <xhtml:link rel="alternate" hreflang> children inside the <url>, the same cluster information as the head tags, in one file.
For very large sites there’s a scale option called a sitemap index: a root /sitemap.xml that doesn’t list URLs itself but points at several child sitemaps, often one per locale. It’s the right move when you’ve outgrown a single file’s practical size. At startup scale you don’t need it; the single typed sitemap.ts with per-entry alternates shown above is simpler and entirely sufficient. The index is named here so you recognize it in the wild, not because it’s recommended yet.
The last step, once the sitemap exists, is to submit /sitemap.xml in Google Search Console so the crawler knows to read it. We’ll cover that in the validation section.
Open Graph locale signals and locale-aware OG images
Section titled “Open Graph locale signals and locale-aware OG images”Search engines aren’t the only machines that read your pages. When someone pastes your link into Slack, posts it on LinkedIn, or shares it on Facebook, those platforms scrape the page to build a preview card: the title, description, and image you’ve seen a thousand times. That preview is driven by Open Graph tags, and it has its own locale dimension. The discipline is the same as hreflang: declare the locale, declare the alternates. There are two parts here, and the first one has a trap the type system won’t catch for you.
og:locale uses underscores, not hyphens
Section titled “og:locale uses underscores, not hyphens”Here’s the trap, stated before anything else so you don’t walk into it. Open Graph’s locale format uses an underscore, fr_FR, not the hyphenated fr-FR you use everywhere else in this codebase. And Next.js’s metadata typing accepts any string for these fields, so passing fr-FR where fr_FR belongs compiles cleanly, renders cleanly, and is silently wrong. There’s no red squiggle and no build error, just a malformed locale that social platforms ignore.
The defense is the same pattern as before: do the conversion in exactly one audited place rather than hand-typing it on every page. A tiny helper in lib/seo.ts handles it:
export const bcp47ToOgLocale = (locale: string) => locale.replace('-', '_'); // 'fr-FR' -> 'fr_FR'This is the one deliberate exception to the “full BCP 47 tag everywhere” rule, and isolating it in a named helper is what keeps it from leaking. Every page derives its OG locale fields through this function, never by typing the underscore form by hand.
openGraph: { locale: bcp47ToOgLocale(locale), alternateLocale: routing.locales .filter((l) => l !== locale) .map(bcp47ToOgLocale),},og:locale is this page’s locale, and og:locale:alternate (the alternateLocale array) is every other locale you offer. Note the .filter((l) => l !== locale) that excludes the page’s own locale, so the alternates never repeat it. The French page declares “I’m the French card (og:locale = fr_FR), and English and German versions also exist.”
Locale-aware OG images
Section titled “Locale-aware OG images”The other half is the image. You built opengraph-image.tsx with ImageResponse in the metadata chapter, the file that renders your share card as an actual image at the edge. For a single locale it draws fixed text. The i18n delta shows up the moment that card contains words: a card reading “Billing software” in English needs to read “Logiciel de facturation” on the French page. A share of /fr-FR/billing that previews English text is a small but real credibility leak.
The fix reuses machinery you already have. opengraph-image.tsx sits under app/[locale]/, so it receives params.locale just like any other route, and it pulls its localized strings with getTranslations, the async, outside-the-render-tree translation function from the wiring lesson, the same form generateMetadata uses.
export default async function Image({ params,}: { params: Promise<{ locale: Locale }>;}) { const { locale } = await params; const t = await getTranslations({ locale, namespace: 'Metadata' });
return new ImageResponse( // ...the same card layout from the metadata chapter, now drawing t('billing.ogTitle') );}Those two highlighted lines are the entire i18n delta: read params.locale like any other route, then load the localized strings with getTranslations. Everything else is the ImageResponse you already know.
Now the decision, because per-locale OG images are real work and not always worth it. Per-locale OG images are a strong signal for translated marketing pages, because those links get shared and a French preview on a French page is worth the effort. Behind authentication the value is weak: private app pages rarely get shared, and when they do the recipient can’t see them anyway. So you reach the same dichotomy as everywhere else: per-locale OG on marketing, default-locale OG inside the app.
One performance note in passing: rendering an OG image on every share request adds latency. For marketing pages the card is effectively static, since the title for /fr-FR/billing doesn’t change between shares, so it should be cached rather than regenerated per request. The cache-warming mechanics were a metadata-chapter topic; the point here is only to flag the cost, so you don’t ship a per-request image renderer on a hot share path.
Handling untranslated pages
Section titled “Handling untranslated pages”So far we’ve assumed every page exists in every locale. Reality is messier. The English /features page is live, but the French catalog for it is empty or half-done, because translation lags shipping. What do you do with /fr-FR/features in the meantime? This is where naive i18n SEO does the most damage: serve it wrong and you’re feeding Google duplicate or empty content under a locale URL, which drags down quality signals for the whole cluster.
There isn’t one right answer; there’s a decision with a sensible default. There are two defensible options.
Option A: fall through to the source locale. Serve the English content under /fr-FR/features, using next-intl’s per-key fallback from the keys-and-catalogs lesson, where a missing French key renders the English value. The page works and the user isn’t staring at blanks. But there’s a hard rule attached: do not list this URL as a French hreflang alternate while its content is actually English. If you do, Google sees English content sitting at a French URL, claimed as the French version, which is duplicate content under a false flag and can penalize the whole cluster. Fall through, but stay out of the hreflang cluster until the real translation lands. This is the right call for app routes, where a fallback prevents broken UX and the page was never an SEO target anyway.
Option B: noindex the untranslated locale. Until the French catalog ships, mark that locale’s variant noindex with metadata.robots = { index: false } on the French page. Google won’t index it, so there’s no duplicate-English-under-French problem and no quality hit. You simply have no French result for that page yet, which is honest. This is the right call for marketing, where SEO quality matters more than coverage: you’d rather show no French result than a duplicate-English one that drags the cluster down.
The chapter’s defaults, stated outright, are Option B for marketing, Option A for app. And the choice shouldn’t live scattered across page files. Document it in lib/i18n.ts, right next to SUPPORTED_LOCALES, as something like a “soft-launch locales” set, so there’s exactly one place that answers “which locales are publicly indexable right now?” When the French catalog ships, you flip it in one file.
This connects to a distinction you met with robots.ts in the metadata chapter, and it’s worth nailing down because people get it backwards. Never use robots.txt to keep a page out of the index. robots.txt blocks crawling: it tells Google not to fetch the page. But if Google can’t fetch the page, it can’t see the noindex directive on the page, so a URL blocked in robots.txt can still end up indexed from external links, with no description, which is the worst of both. “Don’t crawl” is not “don’t rank.” For “this exists but shouldn’t rank yet,” the tool is noindex, which requires Google to crawl the page and read the directive. Crawl it, let it see the noindex, and let it stay out.
Let’s drill the decision, because the matrix is what’s worth remembering, not any single rule.
Sort each page into how its locale variant should be handled for SEO. Watch two axes at once: is the locale actually translated, and is the route a marketing target or behind auth? Drag each item into the bucket it belongs to, then press Check.
/pricing page fully translated into French/features page with complete German copy/blog post with no French translation yet/dashboard shown to a French user, only partly translated/settings page where some keys fall back to EnglishValidating the setup in Search Console and CI
Section titled “Validating the setup in Search Console and CI”This is the through-line of the lesson: this surface is invisible locally. It renders fine, it passes review, and the only feedback that it’s correct comes from outside your dev loop. So you need to know where that feedback actually lives. There are two places.
Google Search Console. After deploying, you submit your sitemap (/sitemap.xml) in Search Console, and Google’s reports, in the International Targeting / hreflang section, surface the errors. The three you’ll see map exactly onto the three rules from earlier: missing return links (a one-sided declaration, rule 2, bidirectionality), malformed language tags (fr where you meant fr-FR, or a country code with no language), and missing x-default. If you internalized the rules, the error report reads like a checklist you already know.
But here’s the catch, and it’s why the structural-helper approach matters so much: these reports populate over days to weeks after Google re-crawls. You cannot iterate on hreflang by trial and error, changing a tag, waiting two weeks, checking the report, and changing it again. The feedback loop is far too slow for that. This is the whole argument for generating the symmetric set from one helper: you make it correct by construction up front, because you won’t get a fast signal telling you it’s broken.
A CI smoke test. The durable, fast-feedback defense is an automated check that runs on every pull request: fetch the rendered HTML for each locale, parse it, and assert that the expected <link rel="alternate" hreflang> tags are present, in the <head>, and symmetric across locales, with the right count per page, pointing at the right URLs. That’s the check that catches a broken helper or a forgotten page before it ships, instead of weeks later in a Search Console report. You won’t build it here: the browser-automation tool for fetching rendered HTML, Playwright, arrives in a later chapter, and the CI smoke-test setup in a testing chapter after that. For now, know that this is the assertion’s home, and know what it checks: head-level hreflang tags, the right number, symmetric. Notice that it asserts <head>. For the JS-executing crawler this is what gets verified after streaming resolves, and the test runs a real browser, so it sees the final DOM.
Keep your expectations calibrated. Search Console is the source of truth but slow; CI is fast but only as good as the assertion you wrote. The combination, correct by construction, verified in CI, and monitored in Search Console, is the full discipline. The project chapter is where you’ll wire all of this into the running invoices app.
External resources
Section titled “External resources”The authoritative references for everything in this lesson. The Google doc is the canonical word on hreflang: when a teammate argues about a rule, this is what settles it.
The canonical reference for hreflang: bidirectional linking, x-default, and how Google clusters language variants.
getPathname documented as the intended tool for building hreflang, canonical URLs, and sitemap entries.
An interactive tool that emits the correct symmetric hreflang set for your URLs — type your locales and read the generated tags.
A 44-minute tour of the single-locale SEO surface this lesson extends: metadata, sitemap.ts, canonical URLs, and Open Graph.
You now have the full i18n SEO surface. Each locale variant is a first-class page that declares its siblings through hreflang, claims its own localized URL as canonical, and appears in the sitemap so crawlers find it, with og:locale carrying the same declaration to social platforms. The recurring trap, the one that quietly deletes translated pages from search, is canonicalizing every locale to the default. The structural defense, a single generateAlternates helper every page calls, makes that bug hard to write and makes the correct shape the default. The next chapter’s project is where this stops being a pattern and becomes wiring on a real, tri-locale app.