0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · Lesson

Localized Metadata, Sitemaps, and hreflang

Generate per-locale titles, descriptions, and hreflang alternates for correct international SEO.

Localized Metadata, Sitemaps, and hreflang is a free Next.js 15 Fullstack (App Router + Server Actions) lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Next.js 15 Fullstack (App Router + Server Actions) learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

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 locale
  • getDictionary — 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-default entry 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.locale uses underscore-separated BCP 47 tags (e.g. fr_FR)
  • openGraph.alternateLocale lists 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 Dictionary interface, cached automatically by Next.js
  • generateMetadata — async, receives params.locale, loads the right dictionary, returns title, description, alternates.canonical, and alternates.languages
  • hreflang alternates — always absolute URLs; include every supported locale plus x-default pointing at the default locale URL
  • OpenGraph locale — set in the root layout with underscore format (fr_FR) and list alternateLocale for other languages
  • generateStaticParams — pair with generateMetadata so every locale is pre-rendered at build time
  • sitemap.ts — use MetadataRoute.Sitemap with alternates.languages per 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.

Frequently asked questions

Is the “Localized Metadata, Sitemaps, and hreflang” lesson free?

Yes — the full text of “Localized Metadata, Sitemaps, and hreflang” is free to read here on the web, and the Next.js 15 Fullstack (App Router + Server Actions) course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Next.js 15 Fullstack (App Router + Server Actions) course, upgrade to CoddyKit PRO.

What will I learn in “Localized Metadata, Sitemaps, and hreflang”?

Generate per-locale titles, descriptions, and hreflang alternates for correct international SEO. You practise Next.js 15 Fullstack (App Router + Server Actions) with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Next.js 15 Fullstack (App Router + Server Actions)?

No prior experience is required. Next.js 15 Fullstack (App Router + Server Actions) on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Localized Metadata, Sitemaps, and hreflang” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Next.js 15 Fullstack (App Router + Server Actions) lesson?

Yes. Every Next.js 15 Fullstack (App Router + Server Actions) lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Locale Detection and Subpath Routing with Middleware
  2. Server-Side Translation Loading and Message Catalogs
  3. Localized Metadata, Sitemaps, and hreflang
  4. Formatting Dates, Numbers, and Plurals by Locale
← Back to Next.js 15 Fullstack (App Router + Server Actions)