Skip to content
Chapter 84Lesson 6

hreflang, per-locale canonicals, and SEO

Make search engines rank each localized page in its own language with hreflang alternates, self-canonicals, sitemaps, and Open Graph locale signals.

Suppose a French user in Paris searches Google for “logiciel de facturation,” billing software, and your SaaS has exactly what she wants. You shipped a /fr-FR/billing page over the last five lessons, every label in French, prices in euros, dates the way she reads them. Google serves her the English /billing page anyway. She bounces off a wall of English in two seconds, and your analytics later read as a French translation that doesn’t convert. The translation was never the problem. Nothing told Google that the French page exists or that it’s the French version of the English one, and search engines don’t infer that from the URL: you have to declare it. Declare it correctly and Google serves each searcher the right locale while both pages share the ranking authority they’ve earned; omit it and your translated pages stay invisible in their own language. This whole surface is invisible during development too, which is why we treat it as structural discipline rather than a feature: it doesn’t show up in local dev, breaks no test, and passes review because the page renders fine, surfacing only as weak organic traffic months later. You build it correctly once because you won’t get fast feedback telling you it’s broken.

You already shipped the hard parts. The per-locale rendering came across the last five lessons, and the single-locale metadata surface came back in the Next.js metadata chapter; this lesson adds exactly one dimension, locale, to metadata you already know how to write, ending with a small lib/seo.ts and the shape of a localized generateMetadata, sitemap.ts, and OG image. One framing carries every section: this is a marketing-route concern. Your public pages, the landing page, /pricing, and /features, are the search target and get the full treatment of hreflang, sitemap entries, and locale-aware social cards. Your authenticated app, /dashboard and /settings, is noindex, since you don’t want Google ranking a logged-out view of a private dashboard; locale resolution still runs there so the UI stays French, but the public SEO surface is dark. Each signal ahead is either marketing-only or both, and I’ll say which.

Why translated pages don’t rank by default

Section titled “Why translated pages don’t rank by default”

The tags in this lesson only make sense once you’ve seen what they fix, so start with the failure.

Picture two URLs: /billing in English and /fr-FR/billing in French. To you they’re the same page in two languages. To Google, with no further information, they’re two unrelated documents that happen to cover the same topic. Google crawls and indexes both, then hits a problem: when a French user searches for billing software, which page should it rank? With no signal that the two are a pair, 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 pages are so similar in structure, Google may flag them as near-duplicates and suppress one, splitting ranking signals that should have reinforced each other.

The fix is to declare the relationship. An annotation called hreflang , a name that fuses href and lang, tells Google that these URLs are the same page in different languages and which is which. Now Google treats them as a cluster: one logical page with several language variants. It pools ranking authority across the cluster instead of making the variants compete, and it serves each user the variant matching their language and region. The French searcher gets /fr-FR/billing, the English searcher gets /billing, and both rank on the strength of the cluster.

Without hreflang
logiciel de facturation fr
app.example.com/billing
Billing software — Acme
Send invoices, track payments, and manage subscriptions for your team…
Lands on English. Bounces.
With hreflang
logiciel de facturation fr
app.example.com/fr-FR/billing
Logiciel de facturation — Acme
Envoyez des factures, suivez les paiements et gérez les abonnements de votre équipe…
Lands on French. Engaged.
The identical French query logiciel de facturation produces two different results. Without hreflang, Google can't tell the variants apart and serves the higher-authority English page — she bounces. With hreflang, the variants form a cluster and Google routes her to the French URL. One set of tags is the difference between capturing that organic traffic and handing it to a competitor.

So what installs that relationship? Four signals, built in order. Three are the core:

  1. hreflang alternates: the tags that declare which URLs are language-siblings of each other.
  2. A self-canonical: each variant declaring itself as the authoritative URL, not a duplicate of the English original.
  3. A sitemap entry: so the crawler reliably discovers every variant in the first place.

The fourth is smaller. 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”

Next.js generates these tags for you from a metadata object, but Google validates hreflang against its own rules regardless of how the tags were produced. Learn only the Next.js field and you’ll write code that compiles, renders, and is silently wrong in Google’s eyes. So learn the tag and the rules Google enforces first; the framework that emits them comes after.

In the <head> of the billing page, the raw tags look like this:

<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 serving that locale’s version of this page. Together they tell Google the billing page exists in English, French, and German at these three URLs. Three rules govern that set, and each is easiest to understand as the failure it prevents.

The failure: each page lists the other languages but omits its own entry. The French page declares English and German, but not French, since declaring itself as French while you’re on the French page feels redundant. Google treats a page that omits its own hreflang entry as not part of the cluster at all, and ignores every declaration on it.

So every page lists all variants, including itself. The French billing page emits a fr-FR entry pointing at its own URL, alongside the en-US and de-DE entries. Ship three locales and every page carries three hreflang entries plus x-default. Self-reference is mandatory, 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 lists /billing as its English alternate, but the English page lists no French alternate, perhaps because it was built first. The declaration is now one-sided: French points to English, but English doesn’t point back. Google requires hreflang relationships to be bidirectional: if A claims B as an alternate, B must claim A in return. When the return link is missing, Google silently discards the declaration as untrustworthy, and your French page competes with English on raw authority as if you’d written no tags at all.

One-sided hreflang looks identical to correct hreflang in your code, your rendered HTML, and local dev. It produces no error anywhere; the only signal is the missing ranking benefit, months later. A hand-maintained list of alternates per page drifts and breaks this way: someone adds a locale and updates four pages but forgets the fifth, or renames a route on the English page but not the German one. The only safe shape is a helper that generates the complete, symmetric set of alternates for every page from one source, so symmetry holds by construction rather than by discipline.

You ship English, French, and German. A Japanese speaker searches and lands in your cluster, matching none of your three entries. Without guidance, Google guesses. With it, you set hreflang="x-default", a pseudo-locale that declares the fallback URL for users whose language matches none of your alternates.

Point x-default at your default-locale URL, as this chapter does, so the Japanese searcher lands on /billing (English), your most-supported variant, rather than a guess. Like the others, the x-default entry goes on every page. Some sites point it at a neutral language-picker page instead; that works too, but a plain default-locale fallback is simpler and fine for most products.

The tags use full locale tags, fr-FR rather than fr. A tag in the BCP 47 format is language-REGION: a language subtag, optionally followed by a region subtag.

Google accepts a region-less code: hreflang="fr" is valid and targets French speakers anywhere. This codebase still uses fr-FR for consistency, since the hreflang value, the URL prefix (/fr-FR/...), and the users.locale column all carry the same string, leaving no mapping layer and no place for fr and fr-FR to drift apart. That’s codebase hygiene, not a Google mandate.

Google does have one hard rule: 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. The language comes first and is required; the region is an optional refinement. Using region to target distinct audiences, such as en-US versus en-GB as separate market variants, is a real technique but out of scope here; this chapter ships language-targeted only.

The rule most teams get wrong combines self-reference and bidirectionality: every variant declares every sibling, both directions, including itself.

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)
Diagonal = self-reference Symmetry across the diagonal = bidirectionality
Every variant declares every sibling, both directions, plus itself. The diagonal is self-reference; the symmetry is bidirectionality. A single page emits one tag per locale, including its own.

That fully-filled, symmetric grid is the target. There’s no special-casing: the French page’s row looks just like the English and German rows. That uniformity is why a single helper can generate every page’s tags, which is what we build next.

Each exercise below is a real pull request that renders fine and ships green; 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?

It competes on raw authority as if it had no hreflang at all — Google drops the half-declared relationship.
It ranks normally; the German page named its English sibling, which is the link that matters.
It throws a crawl error in Search Console within minutes, so the teammate fixes it before launch.

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?

Yes — the other two pages point at it, so the cluster already knows it exists.
No — it never names itself, so Google won’t treat it as a member of the cluster at all.
Yes, as long as one of the three pages carries an x-default tag pointing at it.

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?

It names the URL to serve when none of your declared locales match the searcher — you point it at /pricing, the English default.
It marks English as the highest-authority variant so Google ranks /pricing above the translations.
It is a required redirect that sends every unmatched visitor to the site homepage.

Emitting hreflang with alternates.languages

Section titled “Emitting hreflang with alternates.languages”

Next.js builds the entire hreflang tag set, plus the canonical 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 on the billing page.

// inside generateMetadata for app/[locale]/billing/page.tsx
alternates: {
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.tsx
alternates: {
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.tsx
alternates: {
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.tsx
alternates: {
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.

1 / 1

These are relative paths. metadataBase, the base URL you set in the metadata chapter, resolves them to the absolute URLs Google needs.

That object is correct for the French page. But hand-writing it for every page in every locale, the French, English, and German billing pages, then the same three for pricing, features, and the landing page, means every page needs all the alternates, its own URL as 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. You write it once, as a function, and call it on every page. The helper lives in lib/seo.ts and builds the full languages map by mapping over your configured locales. 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 route through. next-intl’s docs point at getPathname as the intended tool for building hreflang and canonical URLs, and it beats string concatenation for one reason: it already encodes your localePrefix: 'as-needed' setting. Default-locale URLs come back unprefixed (/billing), the others prefixed (/fr-FR/billing), and that logic lives in one place, your routing config, instead of being re-derived in your SEO helper.

src/lib/seo.ts
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 }),
},
};
};

This satisfies all three rules by construction. languages iterates every configured locale, so the self-reference (rule 1) is automatic: the current locale is in routing.locales, so it’s in the map. Every page calls the same function over the same list, so every page emits the same symmetric set, making bidirectionality (rule 2) structural: no page can claim a sibling that doesn’t claim it back. The x-default (rule 3) is appended from the default locale on every page. A reviewer’s job shrinks from “are all the alternates correct and symmetric?” to one question: “does this page call generateAlternates?”

The first argument is the current locale, so the canonical it returns is this page’s own URL, getPathname({ locale, href }) with the page’s own locale. That makes “canonical equals the localized URL” automatic, and it sets up the next section, because getting that canonical wrong is the bug this whole lesson exists to prevent.

One thing trips up experienced developers. You may have absorbed the advice that hreflang tags must be in the <head> or Google ignores them. That was once true 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 been sent, Next.js appends the metadata tags to the end of the <body> rather than the <head>, by design. JavaScript-executing crawlers, Googlebot among them, read the fully-rendered DOM correctly wherever the tags land. Crawlers that don’t run JavaScript (the htmlLimitedBots list, overridable in next.config.ts) are detected by User-Agent and served blocking metadata in the <head> instead.

So don’t “fix” body-appended hreflang by disabling streaming; you’d slow your pages down to solve a problem Next.js already solved. The reflex that matters: metadata and generateMetadata are Server-Component-only. If you see someone hand-injecting <link rel="alternate"> from a Client Component, that’s the bug, with no streaming guarantee and no htmlLimitedBots handling. The framework export on a Server Component is the correct path; anything reaching around it is what to flag in review.

Verify the rendered tags with an automated check that fetches the rendered HTML per locale and asserts the symmetric hreflang set, covered in the validation section.

The canonical is the localized URL, not the default

Section titled “The canonical is the localized URL, not the default”

The one rule to take from this lesson: each locale variant is its own canonical. The French page’s canonical is /fr-FR/billing, the English page’s is /billing, the German page’s is /de-DE/billing. Each variant declares itself as authoritative.

The tempting bug is to point every variant’s canonical at the “real” page, the English original: canonical: '/billing' on the French page, the German page, all of them. English is the source of truth and the translations are derivatives, so surely English is the canonical. It isn’t, and the cost 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 the French page declaring /billing as its canonical tells Google, in the clearest possible terms, that this French page is a duplicate of the English one: don’t index it, don’t rank it, treat it as a copy. Google obliges. Your fully translated, perfectly correct French page vanishes from French search results, someone concludes the translation is broken, and you’re back to the French user in Paris from the start of the lesson, except now you built the wall yourself.

// app/[locale]/billing/page.tsx — the fr-FR page
alternates: {
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 .

The correct version is also less code: you spread the helper’s full return value, canonical included, instead of overriding it with a hardcoded string. The helper from the last section makes the right thing the easy thing; the bug requires you to reach in and break it.

Canonical and hreflang divide the labor, and 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.
  • hreflang answers “where are the other-language versions of this content?” The answer is the sibling URLs.

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 contradict the hreflang, claiming at once that the French page is real (it’s in the cluster) and a duplicate (its canonical is English). Google resolves that by believing the canonical and dropping the page.

This isn’t a new rule. In the Next.js metadata chapter, alternates.canonical handled the single-locale case: a page’s canonical is its own clean URL, with tracking params stripped, so /billing?ref=twitter doesn’t fragment into a hundred near-duplicates. The rule was always “a page’s canonical is its own URL.” Once you have locales, “its own URL” just 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. In the metadata chapter you wrote a single-locale sitemap.ts returning a MetadataRoute.Sitemap: a typed list of 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>.

So the sitemap can carry the same hreflang information as the head tags, and for large sites it’s often the preferred home: every alternate lives in one crawlable file the search engine fetches once, instead of being discovered tag-by-tag across hundreds of separately-fetched pages.

The sitemap file has no metadataBase to lean on, so its URLs must be absolute: you prepend the base URL yourself. But you still build the path with the same getPathname, so the prefix logic stays single-sourced and the sitemap can never disagree with your head tags about where a locale lives.

app/sitemap.ts
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, the dichotomy from the intro made concrete in one constant.

app/sitemap.ts
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.

app/sitemap.ts
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, drawing on the same prefix source of truth generateAlternates uses, so the sitemap and head tags can’t drift apart.

app/sitemap.ts
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.

1 / 1

The scale option, named here so you recognize it in the wild, is a sitemap index: a root /sitemap.xml that lists no URLs itself but points at child sitemaps, often one per locale. You don’t need it at startup scale; the single typed sitemap.ts above is simpler and sufficient.

Once the sitemap exists, submit /sitemap.xml in Google Search Console so the crawler reads it, covered 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, LinkedIn, or Facebook, those platforms scrape the page to build a preview card with a title, description, and image. That card is driven by Open Graph tags, and it has its own locale dimension. The discipline matches hreflang: declare the locale, declare the alternates. The locale tag carries a trap the type system won’t catch.

Open Graph’s locale format uses an underscore, fr_FR, not the hyphenated fr-FR you use everywhere else. Next.js’s metadata typing accepts any string for these fields, so passing fr-FR where fr_FR belongs compiles and renders cleanly while being silently wrong: no error, just a malformed locale that platforms ignore.

Do the conversion in one audited place rather than hand-typing it on every page. A tiny helper in lib/seo.ts handles it:

src/lib/seo.ts
export const bcp47ToOgLocale = (locale: string) =>
locale.replace('-', '_'); // 'fr-FR' -> 'fr_FR'

This is the one deliberate exception to “full BCP 47 tag everywhere”, and isolating it in a named helper keeps it from leaking. Every page derives its OG locale fields through this function.

openGraph: {
locale: bcp47ToOgLocale(locale),
alternateLocale: routing.locales
.filter((l) => l !== locale)
.map(bcp47ToOgLocale),
},

og:locale is this page’s locale; og:locale:alternate (the alternateLocale array) is every other locale you offer. The .filter((l) => l !== locale) 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.”

You built opengraph-image.tsx with ImageResponse in the metadata chapter, the file that renders your share card as an image at the edge. For a single locale it draws fixed text. The i18n delta appears 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.

opengraph-image.tsx sits under app/[locale]/, so it receives params.locale like any other route and pulls its localized strings with getTranslations, the async translation function from the wiring lesson that generateMetadata also uses.

src/app/[locale]/billing/opengraph-image.tsx
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, then load the strings with getTranslations. Everything else is the ImageResponse you already know.

Per-locale OG images are real work, so weigh them. They’re a strong signal for translated marketing pages, whose links get shared and where a French preview on a French page earns its keep. Behind authentication the value is weak: private app pages rarely get shared, and the recipient often 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.

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 cache it rather than regenerate per request. The cache-warming mechanics were a metadata-chapter topic; the point here is only to flag the cost.

Translation lags shipping, so the English /features page goes live while the French catalog for it is still empty or half-done. 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 feed Google duplicate or empty content under a locale URL, dragging down quality signals for the whole cluster. 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 one hard rule comes attached: do not list this URL as a French hreflang alternate while its content is actually English. If you do, Google sees English content 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 the 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. This is the right call for marketing, where SEO quality matters more than coverage: better to show no French result than a duplicate-English one that drags the cluster down.

The chapter’s defaults are Option B for marketing, Option A for app. Don’t scatter the choice across page files. Document it in lib/i18n.ts, right next to SUPPORTED_LOCALES, as a “soft-launch locales” set, so one place answers “which locales are publicly indexable right now?” When the French catalog ships, you flip it in one file.

People get the next part backwards, so nail it down: never use robots.txt to keep a page out of the index. robots.txt blocks crawling, telling Google not to fetch the page at all. 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 get indexed from external links, with no description: 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 needs Google to crawl the page and read the directive.

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.

List as hreflang alternate Fully translated — a real member of the cluster
noindex this locale Marketing, not translated yet
Fall through, don't list App route, source-locale fallback
A marketing /pricing page fully translated into French
A marketing /features page with complete German copy
A marketing /blog post with no French translation yet
A soft-launch locale on the public landing page, catalog still partial
An authenticated /dashboard shown to a French user, only partly translated
An app /settings page where some keys fall back to English

Validating the setup in Search Console and CI

Section titled “Validating the setup in Search Console and CI”

Two places give you feedback on a surface that’s invisible locally.

Google Search Console. After deploying, submit your sitemap (/sitemap.xml); Google’s International Targeting / hreflang reports then surface errors that map onto the three rules: missing return links (rule 2, bidirectionality), malformed language tags (fr for fr-FR, or a country code with no language), and missing x-default. The catch is that these reports populate over days to weeks after a re-crawl, far too slow to fix hreflang by trial and error. That slow loop is the whole argument for generating the symmetric set from one helper: you make it correct by construction, because no fast signal will tell you it’s broken.

A CI smoke test. The fast defense runs on every pull request: fetch the rendered HTML per locale and assert the hreflang tags are head-level, the right count, and symmetric across locales. That catches a broken helper or forgotten page before it ships. You won’t build it here, since Playwright arrives in a later chapter and the CI setup in a testing chapter after that.

The Google doc is the authoritative word on hreflang; the rest cover getPathname, a symmetric-tag generator, and the single-locale SEO surface this lesson extends.

The recurring trap, the one that quietly deletes translated pages from search, is canonicalizing every locale to the default; the generateAlternates helper makes that bug hard to write. The next chapter’s project wires all of this onto a real, tri-locale app.