Skip to content
Chapter 85Lesson 4

Emit 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. There is no signal in the rendered HTML that /fr-FR/pricing is the French version of /pricing, no canonical telling Google which URL to rank, no per-locale Open Graph for the link preview a French prospect sees on LinkedIn. By the end of this lesson the marketing surface emits its full public SEO shape: every page carries bidirectional hreflang with an x-default, a locale-specific canonical, and an OG image that renders in the page’s own language.

There is no screen to admire here — the deliverables are tags in the <head> and entries in /sitemap.xml. The cleanest way to see what you’re building is to view-source on a marketing page and read the <head> excerpt it should produce:

<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 has a Source-HTML hreflang panel and a Sitemap preview panel wired up — they’re empty right now because the marketing pages and the sitemap emit nothing for them to read yet. Build the alternates helper, the sitemap, and the page metadata and both panels fill in, which is how you’ll confirm the work without leaving the app.

This is the slice where i18n discipline stops being about the strings a user reads and becomes structural defense against the ugliest class of SEO bug: the one that renders perfectly in the browser, ships clean, and only surfaces weeks later as a slow bleed of organic traffic you can’t trace. Three decisions sit at the center of that defense, and each has a quiet failure mode that an inexperienced engineer ships without noticing.

The first is the canonical URL. A canonical tag tells Google “of all the URLs that show this content, this is the one to index and rank.” The instinct — canonicalize every locale back to a single clean URL — is exactly backwards for a localized site. If /fr-FR/pricing declares its canonical as https://app.example.com/pricing, you have just told Google the French page is a duplicate of the English one, and Google will drop it from the index and rank only English. Your French organic traffic quietly goes to zero. The canonical on every page must be that page’s own locale-specific URL — every locale is self-canonical. This is the single most common i18n ranking-killer, and it loads fine in a browser, which is why it survives so long.

The second is hreflang. These <link rel="alternate"> tags are how you tell Google “here are the other language versions of this page, serve the right one per user.” Two rules make them work, and Google silently drops the whole declaration if you break either. They must be self-referential — every page lists its own locale among the alternates — and bidirectional — every page lists every supported locale, and the relationship is mutual (if the en-US page points at fr-FR, the fr-FR page must point back at en-US). A one-sided or non-self-referential declaration isn’t an error; it’s just ignored, with no warning. On top of the per-locale entries you add an x-default: the fallback Google serves when a user’s language matches none of your locales. For a SaaS the sensible call is to point x-default 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 speaks BCP 47 with a hyphen — fr-FR. Ship the hyphen and Facebook and LinkedIn treat it as invalid and silently ignore it; your localized link previews fall back to whatever default they guess. One character, no error message.

Most of this you build in this lesson. You write the generateAlternates helper that makes these guarantees hold by construction — it builds the canonical and the full hreflang set from your locale list — the root sitemap.ts that emits one entry per path with the alternates nested inside, and the generateMetadata export on each of the three marketing pages that wires those together with the right path and the resolved locale. The starter hands you the small pieces that are pure plumbing: a bcp47ToOgLocale converter and a per-locale Open Graph image. The discipline worth absorbing as you write generateAlternates is why it’s shaped the way it is: build the alternates from routing.locales rather than listing them by hand, and self-reference plus bidirectionality can’t be forgotten when a fourth locale arrives — they’re a property of the data, not a chore you repeat per page.

A few boundaries. Per-locale OG and hreflang ship on the marketing surface only — that’s where the SEO signal is strong and the authed app is intentionally dark; the authed layout instead declares robots: { index: false } and no alternates, which is its own small discipline (you declare metadata even where you’ve decided not to be indexed, so the decision is explicit rather than accidental). The sitemap is one <url> per canonical path with the locale alternates riding inside each entry — the modern Next-native shape, not a separate sitemap per locale — and it lives at the project root because a sitemap is locale-agnostic. One rule to know but not exercise today: a locale you haven’t translated yet should not advertise a hreflang alternate to its URL (or that URL should noindex) — pointing crawlers at a half-translated page hurts more than it helps. This project ships all three locales fully translated, so it doesn’t bite here, but it’s the rule that governs a rolling locale rollout.

Out of scope: schema.org structured-data localization, A/B locale variants, domain-based locale routing, and translation-management-system integration.

Every marketing page’s rendered HTML lists all three locales plus x-default, bidirectionally, with the page’s own locale self-referenced.
tested
The canonical on each marketing page is that page’s locale-specific URL — /fr-FR/pricing resolves to https://app.example.com/fr-FR/pricing, never the default.
tested
The OG locale tag is the underscore form for the current locale, with the other two locales listed as og:locale:alternate.
tested
/sitemap.xml carries one <url> per canonical path (/, /pricing, /features) with an <xhtml:link rel="alternate" hreflang> per locale inside each entry.
tested
Visiting /fr-FR/’s Open Graph image renders French title text, not English.
untested
The authed app surface declares robots: { index: false } and emits no hreflang alternates.
untested

Implement the generateAlternates helper, the root sitemap.ts, the three marketing generateMetadata exports, and the authed layout’s robots: { index: false } declaration against the brief and the tests, then open the walkthrough below. The bcp47ToOgLocale converter and the per-locale OG image are provided complete — read those for orientation; everything else is code you write.

Reference solution and walkthrough

We’ll work outward from the generateAlternates helper, because once you’ve built the guarantees it encodes, the page wiring is three near-identical calls.

generateAlternates(pathname, currentLocale) is the one 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 it as a stub that returns an empty canonical and {}; here’s the implementation it should hold.

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 URL
export 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 resolved locale of the page being rendered — so /fr-FR/pricing is canonical to itself. This one line is the whole duplicate-content defense; collapse it to routing.defaultLocale and you’ve 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 URL
export 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 languages map is generated by mapping over routing.locales, not by listing locales by hand. Self-reference (the current locale is in the list) and bidirectionality (every page produces the identical set) become 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 URL
export 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, so the localePrefix: 'as-needed' rule is honored — the default locale stays unprefixed, the rest get their prefix.

1 / 1

The mechanics of alternates.languages, the x-default entry, and why the canonical must be locale-specific are covered in depth in chapter 84’s hreflang and canonicals lesson; this seam is the applied version. One callout: APP_URL is a plain constant here because the in-memory substrate has no validated env — in a real deployment this would read a build-validated env.APP_URL so a missing base URL fails the build, not silently ships relative links.

The OG locale converter — read, don’t edit

Section titled “The OG locale converter — read, don’t edit”

The whole point of this file is to absorb the underscore-versus-hyphen trap in one place so no page has to remember it:

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('-', '_');

It’s a one-liner, but it earns its own module: every place that emits an OG locale runs through it, so the hyphen-shaped value never escapes into a meta tag.

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 the 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),
},
};
};

generateMetadata runs per route entry, and the locale param arrives as an unvalidated string — Next will call this for any URL that matches [locale], not only your three. Re-validate with hasLocale and fall back to the default. The component does this same check separately; each entry resolves its own params, so you can’t assume the layout already narrowed it.

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 hook form useTranslations doesn’t apply — you call the async getTranslations with an explicit { locale, namespace }. The title and description come from the marketing.meta catalog, which is why the OG title for /fr-FR/ is genuinely 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 path ('/'); the locale is the resolved one. Pass routing.defaultLocale here instead 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. The filter is what keeps og:locale and og:locale:alternate disjoint — the current locale belongs in one, the rest in the other.

1 / 1

Pricing and features are the identical shape with two values changed — the page’s own path and its marketing.meta keys. Seeing them side by side makes the point: the repetition is deliberate. Each page owns its metadata, and the seam is what 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. Everything else is shared.

The imports each page needs are 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’re adding getTranslations, generateAlternates, and bcp47ToOgLocale where they’re missing.

The authed surface deliberately ships no SEO. But “no SEO” is itself a declaration you make on purpose:

// 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 absence of alternates means no hreflang is emitted there. Writing this explicitly — rather than just not adding metadata — is the difference between “we decided the app is private” and “someone forgot.” The next engineer reads intent, not an accident.

/sitemap.xml is served by app/sitemap.ts at the project root — the last file you write here. 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 — the modern Next-native sitemap, not the older pattern of a separate sitemap file per locale. And it lives at the root, not under [locale]/, because a sitemap describes the whole site across every locale; there’s nothing locale-specific about the document itself. The MetadataRoute.Sitemap shape and the alternates form are covered in chapter 84’s hreflang and canonicals lesson.

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 itself a per-locale route — it 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 is what makes the rendered text language-aware. Because the image lives under [locale]/(marketing)/, Next automatically wires og:image on those pages to the matching per-locale image — you don’t reference it from the metadata yourself.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 4

The suite drives each page’s generateMetadata the way Next does — calling it with a resolving params — and asserts the emitted metadata object, not your source files. A green run looks like this:

✓ 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)

The tests cover the hreflang set, the locale-specific canonical, the OG locale form, and the sitemap shape. The rest you confirm by hand against the inspector — boot the app with pnpm dev and open /inspector:

The Source-HTML hreflang panel shows three alternates plus x-default on every marketing path, bidirectionally — the en-US row lists fr-FR and the fr-FR row lists en-US — with x-default pointing at /.
untested
curl http://localhost:3000/fr-FR/pricing | grep canonical (or view-source) returns the locale-specific URL, not the default.
untested
The Sitemap preview panel shows one <url> per canonical path with three <xhtml:link> alternates each.
untested
/fr-FR/’s og:image points at the French OG image and the fetched image renders French title text; the authed surface carries robots: { index: false }.
untested

To feel why each seam is shaped the way it is, rehearse the failures it prevents. Make one change, observe, and revert before moving on:

Temporarily pass routing.defaultLocale instead of resolved to generateAlternates. Every locale’s canonical collapses to the same English URL — the pages still load fine, but you’ve just told Google the French and British versions are duplicates. This is the silent ranking-killer, with zero error. Revert.
untested
Temporarily drop one locale from the alternates (or hard-code a shorter languages map) and watch the hreflang panel show a one-sided link — the en-US page no longer lists that locale. A declaration Google quietly ignores. Revert.
untested

That completes the project surface. Locale routing, timezone-aware dates and currency, and the public SEO layer are all live and confirmable in the inspector — the invoices list is now genuinely tri-locale, top to bottom.