Internationalization, SEO, and Structured Metadata
Internationalization, SEO, and structured metadata make a Next.js application legible to people, browsers, social crawlers, and search engines in more than one language. The outcome is not only translated text. A good implementation gives each locale a stable URL, emits metadata that matches the visible page, links equivalent pages with language alternates, and exposes structured data that describes the same entity the user can see.
Purpose and Outcome
In this part of the course, the quality goal is discoverability without confusing users or crawlers. A French visitor should land on French content, a search engine should know which English, French, or Japanese URL is canonical for that language, and a shared link should render the right title and description. In the App Router, that means designing locale as part of the route tree, then deriving metadata from the same locale-aware source as the page UI.
How Next.js Handles the Mechanism
Next.js does not translate your copy by itself. It gives you routing, server rendering, metadata generation, static parameter generation, middleware, and streaming boundaries. Internationalization is usually modeled as a dynamic segment such as app/[locale]/.... The segment value becomes input to server components and metadata functions. If the value is known at build time, generateStaticParams can prebuild those locale paths. If the value is request-dependent, the route can be rendered dynamically.
The metadata pipeline is separate from the visual component tree but participates in the same route hierarchy. A layout can define default metadata, a page can override or extend it, and generateMetadata can compute metadata from route params or fetched data. Next.js resolves these pieces into the document <head>. That is where title, description, canonical links, language alternates, Open Graph tags, and robots directives become visible to crawlers.
Structured metadata is different from regular meta tags. JSON-LD is a script containing schema.org vocabulary such as Product, Article, BreadcrumbList, or Organization. It should describe facts already present on the page. Search engines can ignore or penalize mismatched structured data, so treat it as a representation of page content, not a place to advertise hidden claims.
API Anatomy
A practical setup has four moving parts. First, define supported locale codes and one default locale. Second, normalize incoming URLs so every render path has a locale. Third, load dictionaries, CMS records, or product data using that locale. Fourth, build metadata and JSON-LD from those localized values. The important design rule is that metadata should not be hand-maintained separately from the page content because drift creates duplicate, misleading, or stale search results.
export const locales = ["en", "fr", "ja"] as const;
export const defaultLocale = "en";
export type Locale = (typeof locales)[number];
export function isLocale(value: string): value is Locale {
return (locales as readonly string[]).includes(value);
}
export function localePrefix(locale: Locale) {
return locale === defaultLocale ? "" : `/${locale}`;
}
This configuration is intentionally small. The array controls the route surface, the type makes unsupported locales harder to pass around accidentally, and localePrefix centralizes URL generation. In a real application, use BCP 47 language tags consistently, such as en-US when region matters for spelling, currency, or legal copy.
Example 1: Locale Routing
The first example redirects unprefixed public pages to the default locale. A request for /pricing becomes /en/pricing, while /fr/pricing continues unchanged. Static assets and framework internals are skipped so images, JavaScript chunks, and style files are not redirected.
import { NextResponse, type NextRequest } from "next/server";
import { defaultLocale, isLocale } from "./app/i18n/config";
const PUBLIC_FILE = /\.[^/]+$/;
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith("/_next") || PUBLIC_FILE.test(pathname)) {
return NextResponse.next();
}
const firstSegment = pathname.split("/")[1];
if (isLocale(firstSegment)) return NextResponse.next();
const url = request.nextUrl.clone();
url.pathname = `/${defaultLocale}${pathname}`;
return NextResponse.redirect(url, 308);
}
export const config = {
matcher: ["/((?!api).*)"],
};
The deterministic behavior is straightforward: /about redirects with status 308 to /en/about; /ja/about renders normally; /_next/static/app.js is ignored. A permanent redirect is appropriate only after you are sure the URL policy is stable, because clients and intermediaries can cache it.
Example 2: Metadata Per Locale
The next step is to derive page metadata from localized messages. The page title shown in a tab, the description used in search snippets, Open Graph previews, canonical URLs, and language alternates all come from the active locale. This keeps the search result aligned with the page the user actually receives.
import type { Metadata } from "next";
import { isLocale, locales, type Locale } from "../i18n/config";
const messages = {
en: { title: "Pricing", description: "Compare plans for teams building with Acme." },
fr: { title: "Tarifs", description: "Comparez les offres Acme pour les equipes." },
ja: { title: "料金", description: "Acme のチーム向けプランを比較できます。" },
} satisfies Record<Locale, { title: string; description: string }>;
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale: rawLocale } = await params;
const locale = isLocale(rawLocale) ? rawLocale : "en";
const page = messages[locale];
return {
title: page.title,
description: page.description,
alternates: {
canonical: `/${locale}/pricing`,
languages: Object.fromEntries(locales.map((code) => [code, `/${code}/pricing`])),
},
openGraph: {
title: page.title,
description: page.description,
locale,
url: `/${locale}/pricing`,
},
};
}
For /fr/pricing, the expected title is Tarifs, the description is French, and the alternates include en, fr, and ja URLs. The trade-off is duplication in the generated route set: three locales produce three versions of the page. That is usually worthwhile for public marketing, docs, product, and content pages, but it can be excessive for private dashboards whose pages should not be indexed.
Example 3: JSON-LD for the Same Entity
Structured data should be generated from the same record used by the visible page. If the visible pricing page says the team plan costs USD 29, the JSON-LD should say the same thing. The following standalone script demonstrates the shape and expected output without depending on a running Next.js server.
const locales = ["en", "fr", "ja"];
const site = "https://example.com";
function productJsonLd(locale) {
const names = { en: "Team plan", fr: "Offre equipe", ja: "チームプラン" };
return {
"@context": "https://schema.org",
"@type": "Product",
name: names[locale],
url: `${site}/${locale}/pricing`,
offers: {
"@type": "Offer",
priceCurrency: "USD",
price: "29.00",
availability: "https://schema.org/InStock",
},
};
}
for (const locale of locales) {
const data = productJsonLd(locale);
console.log(`${locale}: ${data["@type"]} ${data.name} ${data.offers.priceCurrency} ${data.offers.price}`);
}
Expected output is en: Product Team plan USD 29.00, fr: Product Offre equipe USD 29.00, and ja: Product チームプラン USD 29.00. In an App Router page, you would render the resulting object inside <script type="application/ld+json"> with JSON serialization. Do not concatenate user-authored strings into a script manually; serialize data so quotes and unsafe characters are escaped correctly.
Design Choices and Trade-Offs
Locale prefixes are explicit and crawler-friendly, but they lengthen URLs. Domain-based locales, such as country-specific hostnames, can be strong for regional businesses but increase deployment, analytics, and canonical management complexity. Cookie-only locale selection is convenient for a signed-in app, but it creates weak public URLs because the same URL can show different languages.
Static generation gives fast pages and predictable crawlability, especially when the locale and content set are finite. Dynamic rendering is better when pricing, inventory, or compliance content changes frequently by locale. The risk with dynamic metadata is latency: crawlers and social preview services wait for the head to resolve. Keep metadata fetches narrow, cacheable, and independent from large page queries when possible.
Canonical URLs and alternates must express the same policy. A localized page should usually canonicalize to itself and list peers in other languages. Canonicalizing every locale to English tells crawlers the translated pages are duplicates and may remove them from language-specific search results. Add an x-default alternate when you maintain a global fallback or locale selector page.
Failure Modes and Troubleshooting
Symptom: search results show English titles for translated pages. Cause: metadata is defined only in a parent layout or cached without the locale in the key. Diagnosis: view the rendered source for /fr/... and inspect the final <title> and meta description. Correction: compute page metadata from the locale param and make cached dictionary or CMS fetches include the locale.
Symptom: crawlers report duplicate pages or ignore translated URLs. Cause: canonical links point every locale to one default URL, or alternates omit some locale peers. Diagnosis: compare canonical and hreflang links across every locale version of one page. Correction: canonicalize each language page to itself and emit a complete alternate map for equivalent pages.
Symptom: JSON-LD validation fails or rich results disappear. Cause: required schema fields are missing, localized values are malformed, or structured data claims details not visible on the page. Diagnosis: test the rendered HTML with a structured data validator and compare each JSON-LD field to visible content. Correction: generate schema from typed domain data, remove unsupported claims, and serialize the object with JSON.stringify.
Symptom: users see redirect loops. Cause: middleware redirects already-prefixed paths or applies to internal assets. Diagnosis: log the pathname and response status for one failing request. Correction: skip known locale prefixes, framework paths, API routes, and public files before redirecting.
Security, Performance, and Reliability
Internationalized metadata can leak unpublished content if it fetches draft CMS records or private product data. Use the same publication filters for page body, metadata, sitemap, and JSON-LD. For user-generated titles or descriptions, escape by relying on React and structured serialization rather than string-building HTML.
Performance problems usually come from multiplying work by locale. A site with 200 pages and 8 locales has 1,600 public URLs before pagination, tags, or product variants. Build time, cache storage, sitemap size, and crawl budget all grow. Generate only real locale-page combinations, return notFound for unsupported locales, and avoid producing alternates for pages that are not actually translated.
Hands-On Lab
Prerequisites: a Next.js App Router project, three short dictionaries, and one public page such as pricing, docs, or product detail. Work in a branch so URL and metadata changes can be reviewed together.
- Create a locale config with supported codes and a default locale.
- Move the page under
app/[locale]/pricing/page.tsxand validate the param before loading messages. - Add middleware that redirects unprefixed public paths to the default locale while skipping assets and API routes.
- Implement
generateStaticParamsfor the supported locales if the page is public and finite. - Implement
generateMetadataso title, description, canonical, alternates, and Open Graph fields use localized values. - Add JSON-LD for the page entity and render it from the same domain data as the visible page.
- Run the app locally and request
/pricing,/en/pricing, and/fr/pricing.
Verification: /pricing should redirect to the default locale, localized routes should render without loops, the final HTML head should contain a locale-specific title and description, alternates should list every translated peer, and the JSON-LD object should validate as JSON. Also verify that an unsupported locale such as /xx/pricing returns a controlled 404 or fallback according to your policy.
Cleanup: remove the middleware and route segment changes if the URL policy is rejected, clear any cached redirect in the browser during testing, and restore the previous sitemap or robots output before merging an alternate approach.
Assessment Exercises
- A site has
/en/docs/installand/fr/docs/install. Explain what each page should use as canonical and language alternates, and why canonicalizing both to English is usually wrong. - Design a metadata fetch strategy for a product page where localized names are static but prices change often. Which data belongs in static metadata, and which should stay dynamic or omitted?
- Given a redirect loop on
/fr/pricing, list the middleware checks you would inspect before changing application code. - Write a test assertion that proves JSON-LD and visible page content describe the same product name and price.
- Decide whether an authenticated settings page needs locale-prefixed URLs, indexable metadata, both, or neither. Defend the decision.
Summary
Effective Next.js internationalization treats locale as a first-class route input. SEO metadata is then computed from that route input and the same localized data used by the page. Structured metadata adds machine-readable facts, but only when those facts match visible content. The reliable pattern is explicit locale URLs, validated params, self-consistent canonicals and alternates, serialized JSON-LD, and verification against rendered HTML rather than source assumptions.
