本地化元数据、站点地图与 hreflang
为每种区域设置生成标题、描述和 hreflang 替代项,实现正确的国际化 SEO。
本地化元数据、站点地图与 hreflang 是 CoddyKit 上的免费 Next.js 15 Fullstack (App Router + Server Actions) 课时。 这是第 3 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Next.js 15 Fullstack (App Router + Server Actions) 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。
本课时的部分内容尚未翻译,以英文显示。
Why Localized Metadata Matters
When your Next.js 15 app serves multiple languages, search engines need two things to rank you correctly: per-locale metadata (titles and descriptions in the right language) and hreflang links that tell Google which URL serves which language/region.
Without these, Google may index only your default locale, show the wrong language in search results, or penalise you for duplicate content across /en/about, /fr/about, and /de/about.
- Localized title/description — improves click-through rate in each market
- hreflang alternates — prevents duplicate-content penalties and routes users to their locale
- Canonical URL — confirms the preferred URL for each locale page
Next.js 15 App Router gives you a first-class generateMetadata API that makes all of this straightforward.
Project Setup: Locale Config and Dictionary Types
Before generating metadata, define your supported locales and a typed dictionary shape. A central i18n.ts config keeps everything consistent across metadata, routing, and sitemaps.
Conventions used in this lesson:
locales— array of BCP 47 tags (en,fr,de)defaultLocale— the fallback localegetDictionary— async loader that returns typed strings per locale
// lib/i18n.ts
export const locales = ['en', 'fr', 'de'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'en';
export function isValidLocale(value: string): value is Locale {
return (locales as readonly string[]).includes(value);
}
// Typed shape for SEO-related strings
export interface SeoStrings {
title: string;
description: string;
}
export interface Dictionary {
home: SeoStrings;
about: SeoStrings;
}
Building a Dictionary Loader
The dictionary loader dynamically imports a JSON file for each locale. Using dynamic import() keeps bundle sizes small — each locale's strings are only loaded when needed.
Store dictionaries in messages/ at the project root. The loader is called inside generateMetadata and inside Server Components, so it must be async.
// lib/getDictionary.ts
import type { Locale, Dictionary } from './i18n';
// Next.js caches the result of dynamic imports automatically in the
// App Router, so repeated calls in the same request are free.
const dictionaries: Record<string, () => Promise<Dictionary>> = {
en: () => import('../messages/en.json').then((m) => m.default as Dictionary),
fr: () => import('../messages/fr.json').then((m) => m.default as Dictionary),
de: () => import('../messages/de.json').then((m) => m.default as Dictionary),
};
export async function getDictionary(locale: Locale): Promise<Dictionary> {
const loader = dictionaries[locale];
if (!loader) throw new Error(`No dictionary for locale: ${locale}`);
return loader();
}
Sample Message Files
Each locale needs a JSON file with SEO strings. These are the raw strings that generateMetadata will consume. Keep them short and keyword-rich — they appear directly in <title> and <meta name="description"> tags.
// messages/en.json
{
"home": {
"title": "CoddyKit — Learn to Code",
"description": "Master programming with interactive lessons on CoddyKit."
},
"about": {
"title": "About CoddyKit",
"description": "Learn who we are and our mission to make coding accessible."
}
}
// messages/fr.json
{
"home": {
"title": "CoddyKit — Apprendre la Programmation",
"description": "Maîtrisez la programmation avec des leçons interactives sur CoddyKit."
},
"about": {
"title": "À propos de CoddyKit",
"description": "Découvrez qui nous sommes et notre mission pour rendre le code accessible."
}
}
generateMetadata with Per-Locale Title and Description
generateMetadata is an async function exported from a page.tsx or layout.tsx. Next.js 15 calls it at request time (or at build time for static routes) and injects the result into the <head>.
The params argument gives you the dynamic segment — in an [locale] route, params.locale contains the BCP 47 tag. Load the right dictionary, then return a Metadata object.
// app/[locale]/page.tsx
import type { Metadata } from 'next';
import { getDictionary } from '@/lib/getDictionary';
import { isValidLocale, defaultLocale, type Locale } from '@/lib/i18n';
type Props = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale: rawLocale } = await params;
const locale: Locale = isValidLocale(rawLocale) ? rawLocale : defaultLocale;
const dict = await getDictionary(locale);
return {
title: dict.home.title,
description: dict.home.description,
};
}
export default async function HomePage({ params }: Props) {
const { locale: rawLocale } = await params;
const locale: Locale = isValidLocale(rawLocale) ? rawLocale : defaultLocale;
const dict = await getDictionary(locale);
return <h1>{dict.home.title}</h1>;
}
Adding hreflang Alternates to Metadata
The alternates key in Metadata maps directly to <link rel="alternate" hreflang="..."> tags. You must include:
- One entry per locale with the full absolute URL
- An
x-defaultentry pointing at your default-locale URL (or a language selector page)
Next.js renders these as <link> tags inside <head> automatically — no manual template editing needed.
// app/[locale]/page.tsx (generateMetadata, extended)
import type { Metadata } from 'next';
import { getDictionary } from '@/lib/getDictionary';
import { locales, isValidLocale, defaultLocale, type Locale } from '@/lib/i18n';
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://www.coddykit.com';
type Props = { params: Promise<{ locale: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale: rawLocale } = await params;
const locale: Locale = isValidLocale(rawLocale) ? rawLocale : defaultLocale;
const dict = await getDictionary(locale);
// Build one alternate entry per supported locale
const languages: Record<string, string> = {};
for (const loc of locales) {
languages[loc] = `${BASE_URL}/${loc}`;
}
languages['x-default'] = `${BASE_URL}/${defaultLocale}`;
return {
title: dict.home.title,
description: dict.home.description,
alternates: {
canonical: `${BASE_URL}/${locale}`,
languages,
},
};
}
Sharing Alternate Logic Across Pages
Repeating the alternate-building logic in every generateMetadata function is error-prone. Extract it into a small helper that accepts the current path segment and returns the alternates object.
This helper can live in lib/metadata.ts and be imported anywhere in the app.
// lib/metadata.ts
import { locales, defaultLocale, type Locale } from './i18n';
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://www.coddykit.com';
/**
* Builds the `alternates` block for Next.js Metadata.
* @param path - path after the locale segment, e.g. '' for home, '/about' for about
*/
export function buildAlternates(path = '') {
const languages: Record<string, string> = {};
for (const locale of locales) {
languages[locale] = `${BASE_URL}/${locale}${path}`;
}
languages['x-default'] = `${BASE_URL}/${defaultLocale}${path}`;
return { languages };
}
// Usage in app/[locale]/about/page.tsx:
// alternates: {
// canonical: `${BASE_URL}/${locale}/about`,
// ...buildAlternates('/about'),
// }
Root Layout: Default Metadata and OpenGraph Locale
The root layout.tsx under app/[locale]/ is the right place for default metadata that child pages can override. It is also where you set the <html lang> attribute and the OpenGraph locale field.
openGraph.localeuses underscore-separated BCP 47 tags (e.g.fr_FR)openGraph.alternateLocalelists the other supported locales
// app/[locale]/layout.tsx
import type { Metadata } from 'next';
import { locales, isValidLocale, defaultLocale, type Locale } from '@/lib/i18n';
import { buildAlternates } from '@/lib/metadata';
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://www.coddykit.com';
type Props = {
children: React.ReactNode;
params: Promise<{ locale: string }>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale: rawLocale } = await params;
const locale: Locale = isValidLocale(rawLocale) ? rawLocale : defaultLocale;
const ogLocale = locale.replace('-', '_'); // 'fr' → 'fr', 'pt-BR' → 'pt_BR'
const alternateLocales = locales.filter((l) => l !== locale).map((l) => l.replace('-', '_'));
return {
metadataBase: new URL(BASE_URL),
alternates: {
canonical: `${BASE_URL}/${locale}`,
...buildAlternates(),
},
openGraph: {
locale: ogLocale,
alternateLocale: alternateLocales,
},
};
}
export default async function LocaleLayout({ children, params }: Props) {
const { locale: rawLocale } = await params;
const locale: Locale = isValidLocale(rawLocale) ? rawLocale : defaultLocale;
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}
generateStaticParams for All Locale Pages
For static or ISR pages, export generateStaticParams so Next.js pre-renders a version for every locale at build time. Without this, a fully-static export would only render the default locale.
This function pairs with generateMetadata — Next.js calls both for every combination produced by generateStaticParams.
// app/[locale]/about/page.tsx
import type { Metadata } from 'next';
import { locales, isValidLocale, defaultLocale, type Locale } from '@/lib/i18n';
import { getDictionary } from '@/lib/getDictionary';
import { buildAlternates } from '@/lib/metadata';
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://www.coddykit.com';
// Pre-render /en/about, /fr/about, /de/about at build time
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
type Props = { params: Promise<{ locale: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale: rawLocale } = await params;
const locale: Locale = isValidLocale(rawLocale) ? rawLocale : defaultLocale;
const dict = await getDictionary(locale);
return {
title: dict.about.title,
description: dict.about.description,
alternates: {
canonical: `${BASE_URL}/${locale}/about`,
...buildAlternates('/about'),
},
};
}
export default async function AboutPage({ params }: Props) {
const { locale: rawLocale } = await params;
const locale: Locale = isValidLocale(rawLocale) ? rawLocale : defaultLocale;
const dict = await getDictionary(locale);
return <main><h1>{dict.about.title}</h1></main>;
}
Building a Localized Sitemap
Next.js 15 supports a special app/sitemap.ts file that returns an array of MetadataRoute.Sitemap entries. For an internationalized app, each page appears once per locale, and each entry carries an alternates.languages map — effectively embedding hreflang data inside the sitemap.
Google's Sitemap hreflang spec and the <link rel="alternate"> head tag approach are redundant — you only need one, but using both is safe and recommended for large sites.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { locales, defaultLocale } from '@/lib/i18n';
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://www.coddykit.com';
// Static paths (add dynamic paths from your DB as needed)
const staticPaths = ['', '/about', '/courses'];
export default function sitemap(): MetadataRoute.Sitemap {
const entries: MetadataRoute.Sitemap = [];
for (const path of staticPaths) {
for (const locale of locales) {
const url = `${BASE_URL}/${locale}${path}`;
// Build hreflang alternates for this path
const languages: Record<string, string> = {};
for (const loc of locales) {
languages[loc] = `${BASE_URL}/${loc}${path}`;
}
languages['x-default'] = `${BASE_URL}/${defaultLocale}${path}`;
entries.push({
url,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: path === '' ? 1 : 0.8,
alternates: { languages },
});
}
}
return entries;
}
Dynamic Sitemap Entries from a Database
Most production sites have dynamic pages — blog posts, course pages, product listings. Fetch those slugs inside sitemap.ts (it is a Server-only file) and generate localized entries for each one.
Keep the sitemap fast: fetch only IDs and slugs, not full content. If you have thousands of pages, use Next.js's sitemap splitting feature by exporting multiple sitemap-N.ts files or using generateSitemaps.
// app/sitemap.ts (extended with dynamic course pages)
import type { MetadataRoute } from 'next';
import { locales, defaultLocale } from '@/lib/i18n';
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://www.coddykit.com';
async function getCourseSlugs(): Promise<string[]> {
// Replace with your actual data-fetch (Supabase, fetch, ORM, etc.)
const res = await fetch(`${BASE_URL}/api/courses/slugs`, {
next: { revalidate: 3600 }, // revalidate every hour
});
const data: { slug: string }[] = await res.json();
return data.map((c) => c.slug);
}
function buildEntry(path: string): MetadataRoute.Sitemap[number] {
const languages: Record<string, string> = {};
for (const loc of locales) {
languages[loc] = `${BASE_URL}/${loc}${path}`;
}
languages['x-default'] = `${BASE_URL}/${defaultLocale}${path}`;
return { url: `${BASE_URL}/${defaultLocale}${path}`, lastModified: new Date(),
changeFrequency: 'weekly', priority: 0.7, alternates: { languages } };
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const courseSlugs = await getCourseSlugs();
const coursePaths = courseSlugs.flatMap((slug) =>
locales.map((locale) => ({ locale, path: `/courses/${slug}` }))
);
return [
buildEntry(''),
buildEntry('/about'),
...coursePaths.map(({ path }) => buildEntry(path)),
];
}
Which alternates field is correct for Next.js 15 hreflang?
Your Next.js 15 app supports English, French, and German. You want Google to show the French version at /fr/about when a French user searches. Which generateMetadata return value correctly implements hreflang for the French About page?
Recap: Localized Metadata, Sitemaps, and hreflang
In this lesson you built a complete internationalized SEO layer for a Next.js 15 App Router project. Here is what to remember:
- Dictionary loader — dynamic imports per locale, typed with a shared
Dictionaryinterface, cached automatically by Next.js - generateMetadata — async, receives
params.locale, loads the right dictionary, returnstitle,description,alternates.canonical, andalternates.languages - hreflang alternates — always absolute URLs; include every supported locale plus
x-defaultpointing at the default locale URL - OpenGraph locale — set in the root layout with underscore format (
fr_FR) and listalternateLocalefor other languages - generateStaticParams — pair with
generateMetadataso every locale is pre-rendered at build time - sitemap.ts — use
MetadataRoute.Sitemapwithalternates.languagesper entry; fetch dynamic slugs inside the file (it runs server-side) - buildAlternates helper — extract the languages-map logic into a shared utility to keep page files clean
With these pieces in place, each locale page carries correct metadata and cross-links, Google can index every language variant independently, and users land on the right locale every time.
常见问题解答
「本地化元数据、站点地图与 hreflang」课时是免费的吗?
是的 — 「本地化元数据、站点地图与 hreflang」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Next.js 15 Fullstack (App Router + Server Actions) 课程的其余内容,请升级到 CoddyKit PRO。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。
「本地化元数据、站点地图与 hreflang」这节课中我会学到什么?
为每种区域设置生成标题、描述和 hreflang 替代项,实现正确的国际化 SEO。 你通过在浏览器中直接运行的动手代码来练习 Next.js 15 Fullstack (App Router + Server Actions),全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 Next.js 15 Fullstack (App Router + Server Actions) 需要有经验吗?
无需任何先前经验。CoddyKit 上的 Next.js 15 Fullstack (App Router + Server Actions) 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 3 节课,共 4 节。
「本地化元数据、站点地图与 hreflang」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 Next.js 15 Fullstack (App Router + Server Actions) 课中编写并运行代码吗?
能。每节 Next.js 15 Fullstack (App Router + Server Actions) 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。
此课程中的所有课时
- 使用中间件检测区域设置并进行子路径路由
- 服务端翻译加载与消息目录
- 本地化元数据、站点地图与 hreflang
- 按区域设置格式化日期、数字与复数