hreflang, sitemap alternates, and per-locale OG
The list localizes, the dates respect the viewer’s timezone, the catalogs are full, but to a search crawler your three locales are still invisible. Nothing in the rendered HTML says /fr-FR/pricing is the French version of /pricing, no canonical tells Google which URL to rank, and no per-locale Open Graph shapes the link preview on LinkedIn. This lesson makes every marketing page emit its public SEO shape: bidirectional hreflang with an x-default, a locale-specific canonical, and an OG image in the page’s own language.
There is no screen here; the deliverables are tags in the <head> and entries in /sitemap.xml. View-source on a marketing page should produce this <head> excerpt:
<link rel="canonical" href="https://app.example.com/fr-FR/pricing" /><link rel="alternate" hreflang="en-US" href="https://app.example.com/pricing" /><link rel="alternate" hreflang="en-GB" href="https://app.example.com/en-GB/pricing" /><link rel="alternate" hreflang="fr-FR" href="https://app.example.com/fr-FR/pricing" /><link rel="alternate" hreflang="x-default" href="https://app.example.com/pricing" />The starter’s inspector already wires up a Source-HTML hreflang panel and a Sitemap preview panel, both empty because the pages and the sitemap emit nothing yet. Build the alternates helper, the sitemap, and the page metadata, and both panels fill in, confirming the work without leaving the app.
Your mission
Section titled “Your mission”Three SEO decisions on a localized site each have a failure mode that renders fine in the browser and ships clean, only surfacing later as lost organic traffic.
The first is the canonical URL, which tells Google which of several URLs showing the same content to rank. The instinct, canonicalize every locale back to one clean URL, is backwards: if /fr-FR/pricing names its canonical as https://app.example.com/pricing, you have told Google the French page duplicates the English one, so Google drops it and ranks only English. Every page must be self-canonical, pointing at its own locale-specific URL. This is the most common i18n ranking-killer.
The second is hreflang, the <link rel="alternate"> tags listing a page’s other language versions so Google serves the right one per user. Google silently ignores the whole declaration unless two rules hold: every page lists its own locale (self-referential) and every supported locale, with the relationship mutual (bidirectional) — if en-US points at fr-FR, fr-FR must point back. You also add an x-default, the fallback served when no locale matches; for a SaaS, point it at your strongest-market default, here the unprefixed /.
The third is the OG locale tag. Open Graph uses an underscore form, fr_FR, where the rest of your app uses BCP 47 with a hyphen, fr-FR. Ship the hyphen and Facebook and LinkedIn treat it as invalid and fall back to a guessed default, with no error message.
You write generateAlternates, the root sitemap.ts, and a generateMetadata export on each marketing page; the starter provides the bcp47ToOgLocale converter and per-locale OG images. The reason generateAlternates builds from routing.locales rather than a hand-written list: self-reference and bidirectionality become a property of the data, so a fourth locale can’t break them.
A few boundaries. Per-locale OG and hreflang ship on the marketing pages only; the authed layout instead declares robots: { index: false } and no alternates, stating the no-index decision explicitly. One rule to know but not exercise today: an untranslated locale should not advertise an hreflang alternate to its URL (or that URL should noindex), since pointing crawlers at a half-translated page hurts more than it helps. All three locales here are fully translated, so it governs only a rolling rollout.
Out of scope: schema.org structured-data localization, A/B locale variants, domain-based locale routing, and translation-management-system integration.
x-default, bidirectionally, with the page’s own locale self-referenced./fr-FR/pricing resolves to https://app.example.com/fr-FR/pricing, never the default.og:locale:alternate./sitemap.xml carries one <url> per canonical path (/, /pricing, /features) with an <xhtml:link rel="alternate" hreflang> per locale inside each entry./fr-FR/’s Open Graph image renders French title text, not English.robots: { index: false } and emits no hreflang alternates.Coding time
Section titled “Coding time”Implement the generateAlternates helper, the root sitemap.ts, the three marketing generateMetadata exports, and the authed layout’s robots: { index: false } against the brief and the tests, then open the walkthrough. The bcp47ToOgLocale converter and the per-locale OG image are provided; read them for orientation, write the rest.
Reference solution and walkthrough
Start with generateAlternates. Once it encodes the guarantees, the page wiring is three near-identical calls.
The alternates seam — your work
Section titled “The alternates seam — your work”generateAlternates(pathname, currentLocale) is the helper every marketing page leans on, and the first thing you write. It returns the canonical plus the full languages map that Next renders into hreflang links. The starter ships a stub returning an empty canonical and {}; here is the implementation to put in its place.
import { getPathname } from '@/i18n/navigation';import { routing } from '@/i18n/routing';import type { Locale } from '@/lib/i18n/supported';
// The base URL for absolute SEO URLs. No env validation in this project (the// in-memory substrate has no `env`); production would read this from a validated// `env.APP_URL`.export const APP_URL = 'https://app.example.com';
type Alternates = { canonical: string; languages: Record<string, string>;};
const absolute = (locale: Locale, pathname: string): string => APP_URL + getPathname({ locale, href: pathname });
// The single SEO seam every marketing `generateMetadata` calls. Building the// full set from `routing.locales` is what makes self-reference and// bidirectionality hold by construction:// canonical = the LOCALE-SPECIFIC URL (never collapsed to the default — that// is the duplicate-content trap)// languages = one entry per locale plus `x-default` -> the default-locale URLexport const generateAlternates = ( pathname: string, currentLocale: Locale,): Alternates => ({ canonical: absolute(currentLocale, pathname), languages: { ...Object.fromEntries( routing.locales.map((locale) => [locale, absolute(locale, pathname)]), ), 'x-default': absolute(routing.defaultLocale, pathname), },});The canonical is built from currentLocale, the page’s resolved locale, so /fr-FR/pricing is canonical to itself. Collapse it to routing.defaultLocale and you have told Google the other locales are duplicates.
import { getPathname } from '@/i18n/navigation';import { routing } from '@/i18n/routing';import type { Locale } from '@/lib/i18n/supported';
// The base URL for absolute SEO URLs. No env validation in this project (the// in-memory substrate has no `env`); production would read this from a validated// `env.APP_URL`.export const APP_URL = 'https://app.example.com';
type Alternates = { canonical: string; languages: Record<string, string>;};
const absolute = (locale: Locale, pathname: string): string => APP_URL + getPathname({ locale, href: pathname });
// The single SEO seam every marketing `generateMetadata` calls. Building the// full set from `routing.locales` is what makes self-reference and// bidirectionality hold by construction:// canonical = the LOCALE-SPECIFIC URL (never collapsed to the default — that// is the duplicate-content trap)// languages = one entry per locale plus `x-default` -> the default-locale URLexport const generateAlternates = ( pathname: string, currentLocale: Locale,): Alternates => ({ canonical: absolute(currentLocale, pathname), languages: { ...Object.fromEntries( routing.locales.map((locale) => [locale, absolute(locale, pathname)]), ), 'x-default': absolute(routing.defaultLocale, pathname), },});Mapping languages over routing.locales rather than listing them by hand makes self-reference (the current locale is in the set) and bidirectionality (every page produces the identical set) properties of the data. Add a fourth locale to routing and every page gains its alternate with no edit here.
import { getPathname } from '@/i18n/navigation';import { routing } from '@/i18n/routing';import type { Locale } from '@/lib/i18n/supported';
// The base URL for absolute SEO URLs. No env validation in this project (the// in-memory substrate has no `env`); production would read this from a validated// `env.APP_URL`.export const APP_URL = 'https://app.example.com';
type Alternates = { canonical: string; languages: Record<string, string>;};
const absolute = (locale: Locale, pathname: string): string => APP_URL + getPathname({ locale, href: pathname });
// The single SEO seam every marketing `generateMetadata` calls. Building the// full set from `routing.locales` is what makes self-reference and// bidirectionality hold by construction:// canonical = the LOCALE-SPECIFIC URL (never collapsed to the default — that// is the duplicate-content trap)// languages = one entry per locale plus `x-default` -> the default-locale URLexport const generateAlternates = ( pathname: string, currentLocale: Locale,): Alternates => ({ canonical: absolute(currentLocale, pathname), languages: { ...Object.fromEntries( routing.locales.map((locale) => [locale, absolute(locale, pathname)]), ), 'x-default': absolute(routing.defaultLocale, pathname), },});x-default is the fallback when no alternate matches the user; it points at the default-locale URL. absolute routes everything through getPathname, next-intl’s locale-aware path builder, so localePrefix: 'as-needed' is honored: the default locale stays unprefixed, the rest get their prefix.
Chapter 84’s hreflang and canonicals lesson explains why these guarantees matter; this seam is the applied version. APP_URL is a plain constant here only because the in-memory substrate has no validated env; a real deployment would read a build-validated env.APP_URL.
The OG locale converter — read, don’t edit
Section titled “The OG locale converter — read, don’t edit”Open Graph’s og:locale uses the underscore form (fr_FR), not the BCP 47 hyphen form the rest of the app speaks. This one-liner is the single converter, so the hyphen-shaped value never reaches a meta tag:
import type { Locale } from '@/lib/i18n/supported';
// Open Graph's `og:locale` uses the underscore form (`fr_FR`), not the BCP 47// hyphen form (`fr-FR`) the rest of the app speaks. This is the single converter.export const bcp47ToOgLocale = (locale: Locale): string => locale.replace('-', '_');The three marketing pages — your work
Section titled “The three marketing pages — your work”Each marketing page exports a generateMetadata alongside its component. The home page is the template; pricing and features are the same wiring with the path and translation keys swapped. Here is the home export in full:
export const generateMetadata = async ({ params,}: MarketingHomeProps): Promise<Metadata> => { const { locale } = await params; const resolved = hasLocale(routing.locales, locale) ? locale : routing.defaultLocale; const t = await getTranslations({ locale: resolved, namespace: 'marketing.meta', });
return { title: t('home.title'), description: t('home.description'), alternates: generateAlternates('/', resolved), openGraph: { title: t('home.title'), description: t('home.description'), locale: bcp47ToOgLocale(resolved), alternateLocale: routing.locales .filter((other) => other !== resolved) .map(bcp47ToOgLocale), }, };};locale arrives as an unvalidated string: Next calls this for any URL matching [locale], not only your three. Re-validate with hasLocale and fall back to the default. The component runs the same check separately, since each entry resolves its own params.
export const generateMetadata = async ({ params,}: MarketingHomeProps): Promise<Metadata> => { const { locale } = await params; const resolved = hasLocale(routing.locales, locale) ? locale : routing.defaultLocale; const t = await getTranslations({ locale: resolved, namespace: 'marketing.meta', });
return { title: t('home.title'), description: t('home.description'), alternates: generateAlternates('/', resolved), openGraph: { title: t('home.title'), description: t('home.description'), locale: bcp47ToOgLocale(resolved), alternateLocale: routing.locales .filter((other) => other !== resolved) .map(bcp47ToOgLocale), }, };};generateMetadata is a function, not a component, so the useTranslations hook doesn’t apply; you call the async getTranslations with an explicit { locale, namespace }. The title and description come from the marketing.meta catalog, so the OG title for /fr-FR/ is the French string.
export const generateMetadata = async ({ params,}: MarketingHomeProps): Promise<Metadata> => { const { locale } = await params; const resolved = hasLocale(routing.locales, locale) ? locale : routing.defaultLocale; const t = await getTranslations({ locale: resolved, namespace: 'marketing.meta', });
return { title: t('home.title'), description: t('home.description'), alternates: generateAlternates('/', resolved), openGraph: { title: t('home.title'), description: t('home.description'), locale: bcp47ToOgLocale(resolved), alternateLocale: routing.locales .filter((other) => other !== resolved) .map(bcp47ToOgLocale), }, };};The single call that produces the canonical and the full hreflang set. The path is this page’s ('/'); the locale is the resolved one. Pass routing.defaultLocale here and you reintroduce the duplicate-content bug for every non-default locale.
export const generateMetadata = async ({ params,}: MarketingHomeProps): Promise<Metadata> => { const { locale } = await params; const resolved = hasLocale(routing.locales, locale) ? locale : routing.defaultLocale; const t = await getTranslations({ locale: resolved, namespace: 'marketing.meta', });
return { title: t('home.title'), description: t('home.description'), alternates: generateAlternates('/', resolved), openGraph: { title: t('home.title'), description: t('home.description'), locale: bcp47ToOgLocale(resolved), alternateLocale: routing.locales .filter((other) => other !== resolved) .map(bcp47ToOgLocale), }, };};openGraph.locale is the current locale in underscore form; alternateLocale is the other locales, filtered so the current one isn’t listed twice and og:locale stays disjoint from og:locale:alternate.
Pricing and features are the identical shape with two values changed: the page’s own path and its marketing.meta keys. The repetition is deliberate: each page owns its metadata, and the seam keeps that ownership cheap instead of three copies of the alternate-building logic.
return { title: t('home.title'), description: t('home.description'), alternates: generateAlternates('/', resolved), openGraph: { title: t('home.title'), description: t('home.description'), locale: bcp47ToOgLocale(resolved), alternateLocale: routing.locales .filter((other) => other !== resolved) .map(bcp47ToOgLocale), },};The template. Path '/', keys under home.
return { title: t('pricing.title'), description: t('pricing.description'), alternates: generateAlternates('/pricing', resolved), openGraph: { title: t('pricing.title'), description: t('pricing.description'), locale: bcp47ToOgLocale(resolved), alternateLocale: routing.locales .filter((other) => other !== resolved) .map(bcp47ToOgLocale), },};Two values swapped. Path '/pricing', keys under pricing.
return { title: t('features.title'), description: t('features.description'), alternates: generateAlternates('/features', resolved), openGraph: { title: t('features.title'), description: t('features.description'), locale: bcp47ToOgLocale(resolved), alternateLocale: routing.locales .filter((other) => other !== resolved) .map(bcp47ToOgLocale), },};Same again. Path '/features', keys under features — the third copy proves the seam is the DRY part.
Each page imports the Metadata type, hasLocale from next-intl, getTranslations from next-intl/server, routing, and the two SEO seams. The home page already imports most of these for its component, so you add getTranslations, generateAlternates, and bcp47ToOgLocale where they’re missing.
The authed layout — your work
Section titled “The authed layout — your work”The authed surface ships no SEO on purpose, and “no SEO” is itself a declaration:
// The authed surface is noindex and declares no `alternates` — the discipline of// declaring metadata everywhere, even where the SEO surface is intentionally dark.export const generateMetadata = (): Metadata => ({ robots: { index: false },});robots: { index: false } keeps the app pages out of the search index, and the absent alternates means no hreflang is emitted. Declaring this explicitly, rather than omitting metadata, is the difference between “we decided the app is private” and “someone forgot”: the next engineer reads intent, not an accident.
The sitemap — your work
Section titled “The sitemap — your work”/sitemap.xml is served by app/sitemap.ts at the project root, the last file you write. The starter ships it returning []; your job is one entry per canonical path, with the locale alternates nested inside each entry:
import type { MetadataRoute } from 'next';import { getPathname } from '@/i18n/navigation';import { routing } from '@/i18n/routing';import { APP_URL } from '@/lib/seo/alternates';
// One entry per canonical marketing path. Each carries `alternates.languages`// mapped over `routing.locales` via `getPathname`, so Next emits an// `<xhtml:link>` per locale. Root-level, not under `[locale]/`; absolute URLs.const PATHS = ['/', '/pricing', '/features'] as const;
const sitemap = (): MetadataRoute.Sitemap => PATHS.map((pathname) => ({ url: APP_URL + getPathname({ locale: routing.defaultLocale, href: pathname }), alternates: { languages: Object.fromEntries( routing.locales.map((locale) => [ locale, APP_URL + getPathname({ locale, href: pathname }), ]), ), }, }));
export default sitemap;Two decisions to notice. The shape is one <url> per path with alternates.languages riding inside, which Next renders as nested <xhtml:link rel="alternate" hreflang> elements rather than a separate file per locale. And it lives at the root, not under [locale]/, because a sitemap describes the whole site across every locale. Chapter 84’s hreflang and canonicals lesson covers the MetadataRoute.Sitemap shape and the alternates form.
The Open Graph image — read, don’t edit
Section titled “The Open Graph image — read, don’t edit”This is why /fr-FR/’s social preview renders in French. The OG image is a per-locale route that reads the locale and pulls its title from the same marketing.meta catalog the page metadata uses:
import { ImageResponse } from 'next/og';import { hasLocale } from 'next-intl';import { getTranslations } from 'next-intl/server';import { routing } from '@/i18n/routing';
export const size = { width: 1200, height: 630 };export const contentType = 'image/png';export const alt = 'Invoices';
export const generateStaticParams = () => routing.locales.map((locale) => ({ locale }));
type OgImageProps = { params: Promise<{ locale: string }>;};
const OpengraphImage = async ({ params }: OgImageProps) => { const { locale } = await params; const resolved = hasLocale(routing.locales, locale) ? locale : routing.defaultLocale; const t = await getTranslations({ locale: resolved, namespace: 'marketing.meta', });
return new ImageResponse( <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', width: '100%', height: '100%', padding: 80, background: '#0a0a0a', color: '#fafafa', fontSize: 64, fontWeight: 600, lineHeight: 1.1, }} > {t('home.title')} </div>, size, );};
export default OpengraphImage;generateStaticParams renders one image per locale at build time, and getTranslations with the resolved locale makes the text language-aware. Because the image lives under [locale]/(marketing)/, Next wires og:image on those pages to the matching per-locale image automatically; you don’t reference it from the metadata yourself.
The exact API you export per page — alternates.canonical, alternates.languages, and openGraph.locale, with rendered <head> output.
The MetadataRoute.Sitemap shape with alternates.languages, the per-locale <xhtml:link> form the provided sitemap emits.
getPathname, the locale-aware path builder the alternates seam and sitemap call to honor localePrefix: 'as-needed'.
Why hreflang must be self-referential and bidirectional, plus x-default — the rules the seam enforces by construction.
Moment of truth
Section titled “Moment of truth”Run the test suite:
pnpm test:lesson 4The suite calls each page’s generateMetadata the way Next does and asserts the emitted metadata, not your source files. A green run:
✓ lesson-verification/Lesson 4.ts (25 tests) ✓ Requirement 1 — bidirectional, self-referenced hreflang with x-default ✓ Requirement 2 — canonical is the page's own locale-specific URL ✓ Requirement 3 — og:locale is the underscore form, others as og:locale:alternate ✓ Requirement 4 — sitemap has one entry per canonical path with per-locale alternates
Test Files 1 passed (1) Tests 25 passed (25)Confirm the rest by hand. Run pnpm dev and open /inspector:
x-default on every marketing path, bidirectionally: the en-US row lists fr-FR and vice versa, with x-default at /.curl http://localhost:3000/fr-FR/pricing | grep canonical returns the locale-specific URL, not the default.<url> per canonical path, each with three <xhtml:link> alternates./fr-FR/’s og:image points at the French image, which renders French title text; the authed surface carries robots: { index: false }.Rehearse the failure each seam prevents. Change one thing, observe, then revert:
routing.defaultLocale instead of resolved to generateAlternates. Every canonical collapses to the same English URL: the pages load, but you’ve told Google the French and British versions are duplicates. A silent ranking killer, no error. Revert.