Skill: javainthinking/web-i18n-nextjs (version 1.0.0)

--- name: i18n-nextjs description: > Internationalization (i18n) guide for Next.js / Node.js web applications using the App Router. Covers translation file structure, locale routing, SEO metadata per locale, hreflang, structured JSON-LD data, UI component translations, and multi-language sitemap generation. Use when the user asks to: add i18n support, add a new language, translate a page or component, add SEO metadata for multiple locales, implement hreflang, update the sitemap for new locales, or follow i18n best practices in a Next.js project. --- # i18n for Next.js — Implementation Guide ## Core Principles - All user-facing features **must** implement i18n — no hardcoded strings in components. - Translations must be **natural and idiomatic** — never use scripts or machine translation; treat quality the same as English copywriting. - SEO metadata, JSON-LD structured data, and sitemaps must all be locale-aware. - Default locale (English) uses **clean URLs** with no prefix (`/products`); other locales use a prefix (`/es/products`). ## Supported Locales Locale list lives in `src/lib/i18n/locales.ts`. Keep the sitemap script's `locales` array in sync with this file. ```typescript export const locales = ['en', 'es', 'fr', 'de', 'ja', 'zh-CN', /* ... add as needed */] export const defaultLocale = 'en' export type Locale = typeof locales[number] ``` ## Directory Structure ``` src/app/[lang]/ ├── dictionaries/ ← One JSON file per locale │ ├── en.json │ ├── es.json │ └── ... ├── dictionaries.ts ← getDictionary(locale) server helper ├── layout.tsx ← Root layout: generateMetadata + hreflang + JSON-LD └── <page>/ └── page.tsx ← generateMetadata + page content ``` ## Translation Files See **[references/translation-files.md](references/translation-files.md)** for: - JSON key hierarchy conventions (`page.section.key`) - Server-side `getDictionary()` usage - Client-side `useDictionary()` hook usage - Template variable pattern (`{count}` substitution) - Fallback pattern for missing keys ## Routing & Middleware See **[references/routing.md](references/routing.md)** for: - `src/middleware.ts` — locale detection, redirect `/en/*` → `/*`, rewrite for default locale - `LocalizedLink` component — automatically prefixes non-default locales - `useLocale()` hook — reads locale from URL params → pathname → localStorage → default - `getLocalizedPath()` / `removeLocalePrefix()` utilities ## SEO Metadata See **[references/seo-metadata.md](references/seo-metadata.md)** for: - `generateMetadata()` pattern in layout/page files - `generateAlternatesMetadata()` from `src/lib/i18n/seo.ts` - Full hreflang `alternates.languages` output (all locales + `x-default`) - OpenGraph `locale` / `alternateLocale` fields - `html lang` attribute and `LangSetter` client component ## Structured JSON-LD Data See **[references/structured-data.md](references/structured-data.md)** for: - WebApplication schema with translated `featureList`, `description` - BlogPosting schema with `inLanguage` field - FAQ schema with translated `acceptedAnswer` - BreadcrumbList schema with localized URLs - Rendering via `<Script>` or `<script>` tags ## Multi-language Sitemap See **[references/sitemap.md](references/sitemap.md)** for: - Sitemap structure: one `<url>` entry per page with `<xhtml:link>` alternates for every locale - `<loc>` uses the default-locale (clean) URL; `x-default` also points there - Full XML example with static and dynamic pages - Next.js App Router `sitemap.ts` implementation pattern - What to include vs. exclude (admin/API routes excluded) - Hreflang language code format rules ## Quick Checklist — Adding a New Feature with i18n 1. **Add translation keys** to all locale JSON files in `src/app/[lang]/dictionaries/` - Add English first, then translate to all other languages naturally 2. **Server components**: `const dict = await getDictionary(locale)` → `dict?.page?.section?.key || 'fallback'` 3. **Client components**: `const dict = useDictionary()` → same fallback pattern 4. **Add `generateMetadata()`** to the page file, calling `generateAlternatesMetadata()` 5. **Add JSON-LD** structured data script tag with translated fields and `inLanguage` 6. **Update sitemap** if the page is new: add it to the sitemap source (see [references/sitemap.md](references/sitemap.md)) 7. **Use `<LocalizedLink>`** for internal links and `getLocalizedPath()` for programmatic navigation ## Quick Checklist — Adding a New Locale 1. Add locale code to `locales` array in `src/lib/i18n/locales.ts` 2. Add locale entry to `dictionaries/` as `<code>.json` (full translation of `en.json`) 3. Add entry in `src/app/[lang]/dictionaries.ts` import map 4. Add display name in `LanguageSwitcher` `languageNames` map 5. Sync the sitemap locale list with the app's `locales` array 6. Regenerate / redeploy the sitemap

path: references/translation-files.md
content: # Translation Files Reference

## JSON Key Hierarchy

Keys are organized by page/feature then section:

```json
{
  "common": {
    "loading": "Loading...",
    "error": "An error occurred",
    "success": "Success",
    "cancel": "Cancel",
    "confirm": "Confirm"
  },
  "home": {
    "metadata": {
      "title": "My App — Tagline",
      "description": "What this app does...",
      "keywords": ["keyword one", "keyword two"],
      "ogTitle": "...",
      "ogDescription": "..."
    },
    "hero": {
      "headline": "Welcome headline",
      "cta": "Get started free"
    }
  },
  "dashboard": {
    "someDialog": {
      "title": "Dialog Title",
      "description": "You have {count} items remaining.",
      "submitButton": "Submit",
      "errors": {
        "fileTooLarge": "File must be smaller than 2MB",
        "invalidFormat": "Only PNG, JPG, or WebP images are allowed"
      }
    }
  },
  "blog": {
    "metadata": {
      "title": "Blog",
      "description": "Tips and articles"
    },
    "breadcrumb": {
      "home": "Home",
      "blog": "Blog"
    }
  }
}
```

**Naming conventions:**
- `page.metadata.*` — SEO metadata fields for that page
- `page.section.*` — UI content by section
- `page.dialog.*` or `page.toast.*` — modal/notification text
- `common.*` — shared across multiple pages

## Adding Translation Keys

1. Add key to `en.json` first
2. Add the same key to **every other locale file** with a natural translation
3. Never leave a key missing in a locale — always provide either a real translation or note for the translator

## Server-Side Usage (`getDictionary`)

```typescript
// src/app/[lang]/dictionaries.ts
const dictionaries: Record<Locale, () => Promise<any>> = {
  en: () => import('./dictionaries/en.json').then((m) => m.default),
  es: () => import('./dictionaries/es.json').then((m) => m.default),
  // ... all locales
}

export const getDictionary = async (locale: Locale) => dictionaries[locale]()
```

```typescript
// In a Server Component or page.tsx:
import { getDictionary } from '../dictionaries'

export default async function Page({ params }: { params: { lang: string } }) {
  const locale = params.lang as Locale
  const dict = await getDictionary(locale)

  const t = dict?.home?.hero || {}
  return <h1>{t.headline || 'Welcome'}</h1>
}
```

## Client-Side Usage (`useDictionary`)

```typescript
// src/hooks/use-dictionary.ts
export function useDictionary() {
  const locale = useLocale()
  const [dict, setDict] = useState<any>({})

  useEffect(() => {
    if (dictionaryCache.has(locale)) {
      setDict(dictionaryCache.get(locale))
      return
    }
    dictionaries[locale]().then((loaded) => {
      dictionaryCache.set(locale, loaded)
      setDict(loaded)
    })
  }, [locale])

  return dict
}
```

```typescript
// In a Client Component:
'use client'
import { useDictionary } from '@/hooks/use-dictionary'

export function ItemCountModal({ count }: { count: number }) {
  const dict = useDictionary()
  const t = dict?.dashboard?.someDialog || {}

  return (
    <Dialog>
      <DialogTitle>{t.title || 'Dialog Title'}</DialogTitle>
      <p>
        {(t.description || 'You have {count} items.')
          .replace('{count}', String(count))}
      </p>
    </Dialog>
  )
}
```

## Fallback Pattern

Always provide a fallback string so the UI never breaks if a key is missing:

```typescript
// Good — explicit fallback
const title = dict?.home?.metadata?.title || 'My App — Tagline'

// Good — destructured with fallback object
const t = dict?.dashboard?.feedbackDialog || {}
const label = t.submit || 'Submit'
```

## Template Variable Substitution

Use `{variableName}` placeholders in translation values:

```json
{ "description": "You need {requiredCredits} credits to continue." }
```

```typescript
const message = (t.description || 'You need {requiredCredits} credits.')
  .replace('{requiredCredits}', String(requiredCredits))
```

For pluralization, use separate keys:

```json
{
  "slides_one": "1 slide",
  "slides_other": "{count} slides"
}
```


path: references/routing.md
content: # Routing & Middleware Reference

## URL Pattern

English (default locale) has no prefix — all others use `/<locale>`:

```
/              → English home
/products      → English products
/es            → Spanish home
/es/products   → Spanish products
/ar/products   → Arabic products
/zh-CN/blog    → Simplified Chinese blog
```

Redirect rule: `/en/*` → `/*` (middleware enforces clean English URLs)

## Middleware (`src/middleware.ts`)

```typescript
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
import { NextRequest, NextResponse } from 'next/server'
import { defaultLocale, locales, type Locale } from '@/lib/i18n/locales'

function getLocale(request: NextRequest): string {
  const acceptLang = request.headers.get('accept-language') ?? undefined
  const headers = { 'accept-language': acceptLang || defaultLocale }
  const languages = new Negotiator({ headers }).languages()
  return match(languages, locales, defaultLocale)
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // Skip non-page paths
  if (
    pathname.startsWith('/api/') ||
    pathname.startsWith('/_next/') ||
    pathname.startsWith('/admin') ||
    pathname === '/sitemap.xml' ||
    pathname === '/robots.txt'
  ) {
    return NextResponse.next()
  }

  // Redirect /en/* → /* to keep URLs clean
  if (pathname.startsWith(`/${defaultLocale}/`) || pathname === `/${defaultLocale}`) {
    const clean = pathname === `/${defaultLocale}` ? '/' : pathname.replace(`/${defaultLocale}`, '')
    const url = request.nextUrl.clone()
    url.pathname = clean
    return NextResponse.redirect(url)
  }

  // If pathname already has a supported locale prefix, pass through
  const hasLocale = locales.some(
    (l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`
  )
  if (hasLocale) return NextResponse.next()

  // No locale prefix → rewrite internally to default locale folder
  const rewrite = pathname === '/' ? `/${defaultLocale}` : `/${defaultLocale}${pathname}`
  const url = request.nextUrl.clone()
  url.pathname = rewrite
  return NextResponse.rewrite(url)
}

export const config = {
  matcher: ['/((?!_next|favicon.ico|images|fonts|icons).*)'],
}
```

## `useLocale()` Hook

Priority order: URL param → pathname segment → localStorage → default locale.

```typescript
// src/hooks/use-locale.ts
export function useLocale(): Locale {
  const params = useParams()
  const pathname = usePathname()
  const [hydrated, setHydrated] = useState(false)

  useEffect(() => setHydrated(true), [])

  return useMemo(() => {
    // 1. URL params (e.g., [lang] segment)
    if (params?.lang && locales.includes(params.lang as Locale)) {
      return params.lang as Locale
    }
    // 2. Pathname prefix
    const first = pathname?.split('/').filter(Boolean)[0]
    if (first && locales.includes(first as Locale)) return first as Locale
    // 3. localStorage
    if (hydrated) {
      const stored = localStorage.getItem('locale') as Locale
      if (stored && locales.includes(stored)) return stored
    }
    return defaultLocale
  }, [params, pathname, hydrated])
}
```

## Path Utilities (`src/lib/i18n/utils.ts`)

```typescript
// Add locale prefix (no prefix for default locale)
export function getLocalizedPath(path: string, locale?: Locale): string {
  const target = locale ?? getStoredLocale()
  const clean = path.startsWith('/') ? path : `/${path}`
  return target === defaultLocale ? clean : `/${target}${clean}`
}

// Strip locale prefix from any path
export function removeLocalePrefix(path: string): string {
  const segments = path.split('/').filter(Boolean)
  if (segments.length > 0 && locales.includes(segments[0] as Locale)) {
    return '/' + segments.slice(1).join('/') || '/'
  }
  return path || '/'
}

// Locale persistence
export function storeLocale(locale: Locale): void {
  if (typeof window === 'undefined') return
  locale === defaultLocale
    ? localStorage.removeItem('locale')
    : localStorage.setItem('locale', locale)
}
```

## `LocalizedLink` Component

Wrap `<Link>` to automatically apply locale prefix:

```typescript
// src/components/ui/localized-link.tsx
'use client'
export function LocalizedLink({ href, children, ...props }: LocalizedLinkProps) {
  const locale = useLocale()
  const clean = removeLocalePrefix(href)
  const localizedHref = getLocalizedPath(clean, locale)
  return <Link href={localizedHref} {...props}>{children}</Link>
}
```

Usage:
```tsx
// Renders /products (en) or /es/products (es) automatically
<LocalizedLink href="/products">Products</LocalizedLink>
```

## Language Switcher

```typescript
// src/components/ui/language-switcher.tsx
const languageNames: Record<Locale, string> = {
  en: 'English', es: 'Español', ar: 'العربية',
  ja: '日本語', 'zh-CN': '简体中文', 'zh-TW': '繁體中文',
  // ... all 19
}

function switchLanguage(locale: Locale) {
  storeLocale(locale)
  const clean = removeLocalePrefix(pathname || '/')
  const newPath = locale === defaultLocale ? clean : `/${locale}${clean === '/' ? '' : clean}`
  router.push(newPath)
}
```

## HTML `lang` Attribute

Set server-side in layout, update client-side for SPA navigation:

```typescript
// src/app/[lang]/layout.tsx
export default async function RootLayout({ children, params }) {
  const { lang } = await params
  return (
    <html lang={lang}>
      <body>
        <LangSetter />  {/* Updates document.documentElement.lang on client */}
        {children}
      </body>
    </html>
  )
}
```

```typescript
// src/components/ui/lang-setter.tsx
'use client'
export function LangSetter() {
  const params = useParams()
  useEffect(() => {
    const lang = (params?.lang as Locale) || defaultLocale
    document.documentElement.setAttribute('lang', lang)
  }, [params?.lang])
  return null
}
```

## RTL Support

Arabic (`ar`) requires RTL layout. Detect in layout:

```typescript
const isRtl = lang === 'ar'
return <html lang={lang} dir={isRtl ? 'rtl' : 'ltr'}>
```


path: references/structured-data.md
content: # Structured JSON-LD Data Reference

Always include translated strings and the `inLanguage` field in structured data.
Render via `<Script>` (layout) or `<script>` (page component).

## Table of Contents
- [WebApplication Schema (Homepage / App Landing)](#webapplication-schema)
- [BlogPosting Schema](#blogposting-schema)
- [FAQ Schema with Translations](#faq-schema)
- [BreadcrumbList Schema](#breadcrumblist-schema)
- [How-To Schema](#how-to-schema)

---

## WebApplication Schema

For the main product/homepage layout:

```tsx
// src/app/[lang]/layout.tsx
import Script from 'next/script'

const dict = await getDictionary(locale)
const structured = dict?.home?.structuredData || {}

const webAppSchema = {
  '@context': 'https://schema.org',
  '@type': 'WebApplication',
  name: structured.name || 'MyApp',
  description: structured.description || 'AI-powered presentation generator',
  url: `${baseUrl}/${locale === defaultLocale ? '' : locale}`,
  applicationCategory: 'BusinessApplication',
  operatingSystem: 'Web Browser',
  inLanguage: locale,
  offers: {
    '@type': 'Offer',
    price: '0',
    priceCurrency: 'USD',
    description: structured.offerDescription || 'Free plan available',
  },
  featureList: structured.featureList || ['AI slide generation', 'Templates'],
  aggregateRating: {
    '@type': 'AggregateRating',
    ratingValue: '4.7',
    reviewCount: '1500',
  },
  creator: {
    '@type': 'Organization',
    name: 'MyApp',
    url: 'https://MyApp.com',
  },
}

return (
  <>
    <Script
      id="webapplication-schema"
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(webAppSchema) }}
    />
    {children}
  </>
)
```

**Dictionary keys needed:**
```json
{
  "home": {
    "structuredData": {
      "name": "MyApp",
      "description": "...",
      "offerDescription": "Free plan available",
      "featureList": ["AI slide generation", "Templates", "Export to PPTX"]
    }
  }
}
```

---

## BlogPosting Schema

```tsx
// src/app/[lang]/blog/[slug]/page.tsx
const blogSchema = {
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: blogPost.title,
  description: blogPost.excerpt,
  image: blogPost.featuredImage,
  datePublished: blogPost.publishedAt.toISOString(),
  dateModified: blogPost.updatedAt.toISOString(),
  inLanguage: locale,           // ← required: marks language version
  url: `${baseUrl}/${locale === defaultLocale ? '' : locale + '/'}blog/${blogPost.slug}`,
  author: {
    '@type': 'Organization',
    name: 'App Team',
    url: 'https://MyApp.com',
  },
  publisher: {
    '@type': 'Organization',
    name: 'MyApp',
    logo: {
      '@type': 'ImageObject',
      url: `${baseUrl}/images/logo.svg`,
    },
  },
  mainEntityOfPage: {
    '@type': 'WebPage',
    '@id': blogUrl,
  },
  wordCount: blogPost.wordCount,
  timeRequired: `PT${Math.ceil(blogPost.wordCount / 200)}M`,
}

return (
  <script
    type="application/ld+json"
    dangerouslySetInnerHTML={{ __html: JSON.stringify(blogSchema) }}
  />
)
```

---

## FAQ Schema

Useful for feature pages, help pages, landing pages.

```tsx
const dict = await getDictionary(locale)
const faqs = dict?.featurePage?.faq?.items || []

const faqSchema = {
  '@context': 'https://schema.org',
  '@type': 'FAQPage',
  inLanguage: locale,
  mainEntity: faqs.map((faq: { question: string; answer: string }) => ({
    '@type': 'Question',
    name: faq.question,
    acceptedAnswer: {
      '@type': 'Answer',
      text: faq.answer,
    },
  })),
}
```

**Dictionary keys needed:**
```json
{
  "featurePage": {
    "faq": {
      "items": [
        {
          "question": "How does the AI generate slides?",
          "answer": "Our AI analyzes your input and creates structured slides..."
        }
      ]
    }
  }
}
```

---

## BreadcrumbList Schema

```tsx
const dict = await getDictionary(locale)
const breadcrumb = dict?.blog?.breadcrumb || {}

const breadcrumbSchema = {
  '@context': 'https://schema.org',
  '@type': 'BreadcrumbList',
  itemListElement: [
    {
      '@type': 'ListItem',
      position: 1,
      name: breadcrumb.home || 'Home',
      item: `${baseUrl}${getLocalizedPath('/', locale)}`,
    },
    {
      '@type': 'ListItem',
      position: 2,
      name: breadcrumb.blog || 'Blog',
      item: `${baseUrl}${getLocalizedPath('/blog', locale)}`,
    },
    {
      '@type': 'ListItem',
      position: 3,
      name: blogPost.title,
      item: `${baseUrl}${getLocalizedPath(`/blog/${blogPost.slug}`, locale)}`,
    },
  ],
}
```

---

## How-To Schema

For tutorial/guide pages:

```tsx
const steps = dict?.howToPage?.steps || []

const howToSchema = {
  '@context': 'https://schema.org',
  '@type': 'HowTo',
  name: dict?.howToPage?.title || 'How to create slides',
  inLanguage: locale,
  description: dict?.howToPage?.description || '',
  step: steps.map((step: { name: string; text: string }, i: number) => ({
    '@type': 'HowToStep',
    position: i + 1,
    name: step.name,
    text: step.text,
  })),
}
```

---

## Script Placement

- **`<Script>` from `next/script`** — use in layouts (avoids hydration issues)
- **`<script>`** — use in page components for content-level schemas (BlogPosting, FAQ)
- Place before `</body>` or within the JSX return of Server Components
- Use unique `id` props when using `<Script>` to prevent duplicates


path: references/seo-metadata.md
content: # SEO Metadata Reference

## `generateMetadata()` Pattern

Every page and layout that serves user-facing content must export `generateMetadata`.

```typescript
// src/app/[lang]/some-page/page.tsx
import { type Metadata } from 'next'
import { getDictionary } from '../dictionaries'
import { generateAlternatesMetadata } from '@/lib/i18n/seo'
import { type Locale } from '@/lib/i18n/locales'

export async function generateMetadata({
  params,
}: {
  params: Promise<{ lang: string }>
}): Promise<Metadata> {
  const { lang } = await params
  const locale = lang as Locale
  const dict = await getDictionary(locale)
  const meta = dict?.somePage?.metadata || {}

  const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://MyApp.com'
  const pathname = `/${lang}/some-page`
  const alternates = generateAlternatesMetadata(locale, pathname, baseUrl, true)

  const title = meta.title || 'Default Title'
  const description = meta.description || 'Default description'

  return {
    metadataBase: new URL(baseUrl),
    title,
    description,
    keywords: meta.keywords || [],
    alternates,             // ← hreflang for all 19 locales
    openGraph: {
      title: meta.ogTitle || title,
      description: meta.ogDescription || description,
      url: `${baseUrl}/${lang}/some-page`,
      locale: lang === 'zh-CN' ? 'zh_CN' : lang.replace('-', '_'),
      alternateLocale: locales
        .filter((l) => l !== lang)
        .map((l) => (l === 'zh-CN' ? 'zh_CN' : l.replace('-', '_'))),
      type: 'website',
      images: [{ url: '/images/og-image.webp', width: 1200, height: 630 }],
    },
    twitter: {
      card: 'summary_large_image',
      title: meta.ogTitle || title,
      description: meta.ogDescription || description,
      images: ['/images/og-image.webp'],
    },
  }
}
```

## `generateAlternatesMetadata()` — `src/lib/i18n/seo.ts`

```typescript
export function generateAlternatesMetadata(
  currentLang: Locale,
  pathname: string,
  baseUrl: string,
  useDefaultLocaleAsRoot: boolean = true
) {
  const pathWithoutLang = getPathnameWithoutLang(pathname)
  const languages = generateHreflangUrls(currentLang, pathWithoutLang, baseUrl, useDefaultLocaleAsRoot)

  const canonical =
    currentLang === defaultLocale && useDefaultLocaleAsRoot
      ? pathWithoutLang
      : `/${currentLang}${pathWithoutLang}`

  return { canonical, languages }
}

export function generateHreflangUrls(
  currentLang: Locale,
  pathname: string,   // path WITHOUT locale prefix
  baseUrl: string,
  useDefaultLocaleAsRoot: boolean = true
): Record<string, string> {
  const urls: Record<string, string> = {}

  for (const locale of locales) {
    urls[locale] =
      locale === defaultLocale && useDefaultLocaleAsRoot
        ? `${baseUrl}${pathname}`
        : `${baseUrl}/${locale}${pathname}`
  }

  // x-default → default locale
  urls['x-default'] = useDefaultLocaleAsRoot
    ? `${baseUrl}${pathname}`
    : `${baseUrl}/${defaultLocale}${pathname}`

  return urls
}
```

**Output (Next.js converts to `<link rel="alternate" hreflang="...">`):**
```html
<link rel="alternate" hreflang="en"      href="https://MyApp.com/some-page"/>
<link rel="alternate" hreflang="es"      href="https://MyApp.com/es/some-page"/>
<link rel="alternate" hreflang="ar"      href="https://MyApp.com/ar/some-page"/>
<link rel="alternate" hreflang="zh-CN"   href="https://MyApp.com/zh-CN/some-page"/>
<!-- ... all 19 locales ... -->
<link rel="alternate" hreflang="x-default" href="https://MyApp.com/some-page"/>
```

## Dictionary Keys for Metadata

In each locale's JSON, the `metadata` block should contain:

```json
{
  "somePage": {
    "metadata": {
      "title": "Page Title — Site Name",
      "description": "Localized meta description (140–160 chars).",
      "keywords": ["keyword 1", "keyword 2"],
      "ogTitle": "OG title (can differ from meta title)",
      "ogDescription": "OG description for social sharing."
    }
  }
}
```

Translations must be written in the target language — not machine-translated copies of English.

## Blog / Dynamic Pages

For pages with dynamic data (blog post, template detail), merge static dictionary strings with dynamic content:

```typescript
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { lang, slug } = await params
  const locale = lang as Locale
  const dict = await getDictionary(locale)
  const blogPost = await getBlogPost(slug)

  const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://MyApp.com'
  const pathname = `/${lang}/blog/${blogPost.slug}`
  const alternates = generateAlternatesMetadata(locale, pathname, baseUrl, true)

  return {
    title: `${blogPost.title} | Blog`,
    description: blogPost.excerpt || dict?.blog?.metadata?.description,
    alternates,
    openGraph: {
      type: 'article',
      publishedTime: blogPost.publishedAt.toISOString(),
      locale: lang === 'zh-CN' ? 'zh_CN' : lang.replace('-', '_'),
      images: [{ url: blogPost.featuredImage, width: 1200, height: 630 }],
    },
  }
}
```

## Canonical URL Rules

| Locale         | Canonical URL                        |
|----------------|--------------------------------------|
| `en` (default) | `https://MyApp.com/some-page`      |
| `es`           | `https://MyApp.com/es/some-page`   |
| `zh-CN`        | `https://MyApp.com/zh-CN/some-page`|
| `x-default`    | `https://MyApp.com/some-page`      |

English canonical has **no** `/en` prefix. This is enforced by `useDefaultLocaleAsRoot: true`.


path: references/sitemap.md
content: # Multi-Language Sitemap Reference

## Core Principle

In an i18n site, **every URL has multiple language versions**. The sitemap must:
1. List every URL once (using the canonical/default locale URL as `<loc>`)
2. Attach `<xhtml:link rel="alternate">` entries for **all locale variants** of that URL, including `x-default`

This tells search engines that these URLs are the same page in different languages, not duplicate content.

---

## URL Structure

With default locale = `en` and no `/en` prefix for English:

| Page      | English (default)          | Spanish                      | Japanese                     |
|-----------|----------------------------|------------------------------|------------------------------|
| Home      | `https://example.com/`     | `https://example.com/es/`    | `https://example.com/ja/`    |
| Products  | `https://example.com/products` | `https://example.com/es/products` | `https://example.com/ja/products` |
| Blog post | `https://example.com/blog/my-post` | `https://example.com/es/blog/my-post` | `https://example.com/ja/blog/my-post` |

The `<loc>` in the sitemap uses the **English (default) URL**. All locale versions are listed in `<xhtml:link>` alternates.

---

## XML Structure per URL Entry

```xml
<url>
  <!-- Canonical URL (default locale, no language prefix) -->
  <loc>https://example.com/products</loc>

  <!-- Alternate for every supported locale -->
  <xhtml:link rel="alternate" hreflang="en"    href="https://example.com/products"/>
  <xhtml:link rel="alternate" hreflang="es"    href="https://example.com/es/products"/>
  <xhtml:link rel="alternate" hreflang="fr"    href="https://example.com/fr/products"/>
  <xhtml:link rel="alternate" hreflang="de"    href="https://example.com/de/products"/>
  <xhtml:link rel="alternate" hreflang="ja"    href="https://example.com/ja/products"/>
  <xhtml:link rel="alternate" hreflang="zh-CN" href="https://example.com/zh-CN/products"/>
  <!-- ... one entry per locale ... -->

  <!-- x-default points to the default locale URL -->
  <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/products"/>

  <lastmod>2025-01-15</lastmod>
  <changefreq>weekly</changefreq>
  <priority>0.8</priority>
</url>
```

**Rules:**
- `<loc>` = canonical URL (always the default locale / clean URL)
- Every locale gets exactly one `<xhtml:link>` alternate
- `x-default` typically points to the same URL as the default locale
- All URLs within one `<url>` block must reference each other (Google requirement: alternates must be consistent across all language pages)

---

## Full Sitemap Document Structure

```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset
  xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
  xmlns:xhtml="http://www.w3.org/1999/xhtml">

  <!-- Home page -->
  <url>
    <loc>https://example.com/</loc>
    <xhtml:link rel="alternate" hreflang="en"       href="https://example.com/"/>
    <xhtml:link rel="alternate" hreflang="es"       href="https://example.com/es/"/>
    <xhtml:link rel="alternate" hreflang="fr"       href="https://example.com/fr/"/>
    <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/"/>
    <lastmod>2025-01-15</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>

  <!-- Static page -->
  <url>
    <loc>https://example.com/about</loc>
    <xhtml:link rel="alternate" hreflang="en"       href="https://example.com/about"/>
    <xhtml:link rel="alternate" hreflang="es"       href="https://example.com/es/about"/>
    <xhtml:link rel="alternate" hreflang="fr"       href="https://example.com/fr/about"/>
    <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/about"/>
    <lastmod>2025-01-10</lastmod>
    <changefreq>monthly</changefreq>
    <priority>0.6</priority>
  </url>

  <!-- Dynamic page (e.g. blog post) -->
  <url>
    <loc>https://example.com/blog/my-post-slug</loc>
    <xhtml:link rel="alternate" hreflang="en"       href="https://example.com/blog/my-post-slug"/>
    <xhtml:link rel="alternate" hreflang="es"       href="https://example.com/es/blog/my-post-slug"/>
    <xhtml:link rel="alternate" hreflang="fr"       href="https://example.com/fr/blog/my-post-slug"/>
    <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/blog/my-post-slug"/>
    <lastmod>2025-01-20</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.7</priority>
  </url>

</urlset>
```

---

## What to Include vs. Exclude

**Include:**
- All public-facing pages (home, feature pages, pricing, about, contact)
- Dynamic content pages (blog posts, product detail pages, etc.)
- Each page listed **once** using the default-locale URL as `<loc>`

**Exclude:**
- Admin / internal routes (`/admin`, `/dashboard`, etc.)
- API routes (`/api/...`)
- Auth pages (`/login`, `/signup`) — optional, low SEO value
- Pages behind authentication
- Duplicate or near-duplicate pages (`/en/...` redirect targets)

---

## Implementation Approaches

### Option 1: Next.js built-in `sitemap.ts` (recommended for App Router)

```typescript
// src/app/sitemap.ts
import { MetadataRoute } from 'next'
import { locales, defaultLocale } from '@/lib/i18n/locales'

const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://example.com'

function buildAlternates(path: string) {
  const languages: Record<string, string> = {}
  for (const locale of locales) {
    languages[locale] = locale === defaultLocale
      ? `${baseUrl}${path}`
      : `${baseUrl}/${locale}${path}`
  }
  languages['x-default'] = `${baseUrl}${path}`
  return { languages }
}

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // Static pages
  const staticPages = ['/', '/about', '/pricing', '/blog'].map((path) => ({
    url: `${baseUrl}${path}`,
    lastModified: new Date(),
    changeFrequency: 'weekly' as const,
    priority: path === '/' ? 1.0 : 0.7,
    alternates: buildAlternates(path),
  }))

  // Dynamic pages (e.g. blog posts from DB/CMS)
  const posts = await fetchBlogPosts()
  const dynamicPages = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: 'weekly' as const,
    priority: 0.6,
    alternates: buildAlternates(`/blog/${post.slug}`),
  }))

  return [...staticPages, ...dynamicPages]
}
```

Next.js automatically outputs `<xhtml:link>` tags when `alternates.languages` is provided.

### Option 2: Custom script (non-App Router or more control needed)

Generate `public/sitemap.xml` with a Node.js script that:
1. Collects all paths (static list + dynamic from DB/CMS/API)
2. For each path, expands to all locale URLs
3. Writes the XML with the structure shown above

---

## Hreflang Language Code Rules

| Locale         | `hreflang` value |
|----------------|------------------|
| English        | `en`             |
| Spanish        | `es`             |
| French         | `fr`             |
| Simplified Chinese | `zh-CN`      |
| Traditional Chinese | `zh-TW`     |
| Portuguese (Brazil) | `pt-BR`    |
| Portuguese (Portugal) | `pt-PT`  |
| Default fallback | `x-default`    |

- Use IETF language tags: `language` or `language-REGION`
- `x-default` is required — point it to the default locale URL
- Must be consistent: every locale page must list all the same alternates

---

## Keeping Locales in Sync

The sitemap's locale list must always match the app's `locales` array in `src/lib/i18n/locales.ts`. When adding or removing a locale, update both in the same commit.


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: suspicious | SkillSpector: clean (LOW)