{
  "slug": "i18n",
  "title": "Internationalization (i18n)",
  "description": "Multi-language content with automatic AI translation, locale routing, and hreflang.",
  "category": "guides",
  "order": 3,
  "locale": "en",
  "translationGroup": "ac48668a-63fd-41de-9fe8-24d33407b531",
  "helpCardId": null,
  "content": "## ⚠️ CRITICAL: translationGroup is MANDATORY for multilingual sites\n\n**If you are building a site with 2+ languages, every document that has a translation MUST have a `translationGroup` field.** Without it:\n\n- The admin **side-by-side editor is broken** — editors cannot see EN and DA next to each other\n- The **language switcher in the UI does not work** — documents appear as unrelated orphans\n- **AI bulk translate** cannot find which documents belong together\n- **Hreflang generation** produces incomplete or wrong alternate links\n\nThis is the #1 mistake AI builders make on multilingual sites. Set it on every document before writing any content.\n\n```json\n// EN variant\n{ \"slug\": \"about-us\", \"locale\": \"en\", \"translationGroup\": \"550e8400-e29b-41d4-a716-446655440000\", ... }\n\n// DA variant — SAME translationGroup value\n{ \"slug\": \"om-os\",    \"locale\": \"da\", \"translationGroup\": \"550e8400-e29b-41d4-a716-446655440000\", ... }\n```\n\nGenerate a new UUID per page/post (NOT per translation): `import { randomUUID } from 'crypto'; const groupId = randomUUID();`\n\n## Configure locales\n\n```typescript\nexport default defineConfig({\n  defaultLocale: 'en',\n  locales: ['en', 'da'],\n\n  collections: [\n    defineCollection({\n      name: 'posts',\n      sourceLocale: 'en',\n      locales: ['en', 'da'],\n      translatable: true,\n      fields: [\n        { name: 'title', type: 'text', required: true },\n        { name: 'content', type: 'richtext' },\n      ],\n    }),\n  ],\n});\n```\n\n## How translations work\n\nEach translation is a **separate document** linked via `translationGroup` — a shared UUID connecting all language versions:\n\n```json\n// content/posts/hello-world.json (English)\n{\n  \"slug\": \"hello-world\",\n  \"locale\": \"en\",\n  \"translationGroup\": \"abc-123\",\n  \"data\": { \"title\": \"Hello, World!\" }\n}\n\n// content/posts/hello-world-da.json (Danish)\n{\n  \"slug\": \"hello-world-da\",\n  \"locale\": \"da\",\n  \"translationGroup\": \"abc-123\",\n  \"data\": { \"title\": \"Hej, Verden!\" }\n}\n```\n\n## Admin UI translation workflow\n\nIn the admin UI:\n1. Open a document — see the locale badge showing current language\n2. Click **\"+ Add translation\"** to create a new locale version\n3. The AI translator auto-translates all fields\n4. Review and publish the translation\n\nTranslations appear grouped in the document list and the editor shows a locale switcher.\n\n## AI translation\n\n```typescript\nimport { createAi } from '@webhouse/cms-ai';\n\nconst ai = await createAi();\nconst result = await ai.content.translate(\n  sourceDoc.data,\n  'da',\n  { collection: collectionConfig },\n);\n// result.fields contains translated data\n```\n\n## Locale routing in Next.js\n\nUse a `[locale]` route segment:\n\n```\napp/\n  [locale]/\n    blog/\n      [slug]/page.tsx\n    page.tsx\n  layout.tsx\n```\n\nWith a middleware for locale detection:\n\n```typescript\n// middleware.ts\nimport { NextRequest, NextResponse } from 'next/server';\n\nconst LOCALES = ['en', 'da'];\nconst DEFAULT = 'en';\n\nexport function middleware(request: NextRequest) {\n  const { pathname } = request.nextUrl;\n  const hasLocale = LOCALES.some(l => pathname.startsWith(`/${l}/`) || pathname === `/${l}`);\n  if (hasLocale) return;\n\n  const preferred = request.headers.get('accept-language')?.split(',')[0]?.split('-')[0] ?? DEFAULT;\n  const locale = LOCALES.includes(preferred) ? preferred : DEFAULT;\n  return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url));\n}\n```\n\n## Translation Groups (translationGroup)\n\nEvery translated document is linked to its source via a shared `translationGroup` UUID. This is the core mechanism that connects EN and DA (or any locale pair) in the CMS.\n\n### How it works\n\n1. When you create a document, it gets a unique `translationGroup` UUID\n2. When you create a translation (via admin UI or script), the new document gets the **same** `translationGroup`\n3. CMS admin uses this to show locale badges, language switcher, and side-by-side editing\n\n### Document structure\n\n```json\n// content/posts/hello-world.json (English — source)\n{\n  \"slug\": \"hello-world\",\n  \"locale\": \"en\",\n  \"translationGroup\": \"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\n  \"data\": { \"title\": \"Hello, World!\" }\n}\n\n// content/posts/hello-world-da.json (Danish — translation)\n{\n  \"slug\": \"hello-world-da\",\n  \"locale\": \"da\",\n  \"translationGroup\": \"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\n  \"data\": { \"title\": \"Hej, Verden!\" }\n}\n```\n\nBoth documents share the same `translationGroup` UUID. That is the **only** link between them — there is no parent/child relationship, no `translationOf` field needed.\n\n### Rules for AI builders and scripts\n\n1. **Always set `locale`** on every document (`\"en\"`, `\"da\"`, etc.)\n2. **Always set `translationGroup`** — use `crypto.randomUUID()` for new documents\n3. **Share the same `translationGroup`** across all locale versions of a document\n4. **Slug convention**: source slug + locale suffix (e.g. `hello-world` → `hello-world-da`)\n5. **Never duplicate `translationGroup`** across unrelated documents\n\n### Pairing script example\n\n```typescript\nimport { readFileSync, writeFileSync, readdirSync } from 'fs';\nimport { join } from 'path';\nimport { randomUUID } from 'crypto';\n\nconst DIR = 'content/docs';\nconst files = readdirSync(DIR).filter(f => f.endsWith('.json'));\n\n// Build EN → DA map\nconst enDocs = new Map();\nconst daDocs = new Map();\nfor (const f of files) {\n  const slug = f.replace('.json', '');\n  if (slug.endsWith('-da')) daDocs.set(slug.replace(/-da$/, ''), join(DIR, f));\n  else enDocs.set(slug, join(DIR, f));\n}\n\n// Pair them\nfor (const [slug, enPath] of enDocs) {\n  const daPath = daDocs.get(slug);\n  if (!daPath) continue;\n\n  const enDoc = JSON.parse(readFileSync(enPath, 'utf-8'));\n  const daDoc = JSON.parse(readFileSync(daPath, 'utf-8'));\n\n  const tg = enDoc.translationGroup || randomUUID();\n  enDoc.translationGroup = tg;\n  enDoc.locale = 'en';\n  daDoc.translationGroup = tg;\n  daDoc.locale = 'da';\n\n  writeFileSync(enPath, JSON.stringify(enDoc, null, 2));\n  writeFileSync(daPath, JSON.stringify(daDoc, null, 2));\n}\n```\n\n### Site config for i18n\n\nIn CMS admin Site Settings → Language:\n\n- **Default language**: `en` (or your source locale)\n- **Supported languages**: add `da` (or any target locales)\n- **Locale strategy**:\n  - `prefix-other` — URL prefix for non-default locales: `/da/blog/slug`\n  - `prefix-all` — URL prefix for all locales: `/en/blog/slug`, `/da/blog/slug`\n  - `none` — no URL prefix, locale is in the slug: `/blog/slug-da`\n\n### What CMS admin does with translationGroup\n\n- **Locale badge** on documents showing current language\n- **Language switcher** in editor to jump between translations\n- **\"+ Add translation\"** button to create a new locale version (triggers AI translation)\n- **Side-by-side editing** when comparing source and translation\n- **Translation status** tracking (stale, up-to-date, missing)\n\n\n## Locale Strategy & Default Locale\n\nThese two settings control how localized content appears in URLs and how CMS admin constructs preview links.\n\n### defaultLocale\n\nThe primary authoring language. Set in Site Settings → Language.\n\n```\ndefaultLocale: \"en\"\n```\n\nDocuments in the default locale have no URL prefix or suffix — they use plain slugs:\n- EN (default): `/blog/my-post`\n- DA (non-default): depends on localeStrategy\n\n### localeStrategy\n\nControls how non-default locales appear in URLs:\n\n| Strategy | EN (default) URL | DA URL | When to use |\n|----------|-----------------|--------|-------------|\n| `prefix-other` | `/blog/my-post` | `/da/blog/my-post` | Most sites — clean default URLs, prefix for other locales |\n| `prefix-all` | `/en/blog/my-post` | `/da/blog/my-post` | When you want explicit locale in ALL URLs |\n| `none` | `/blog/my-post` | `/blog/my-post-da` | When locale is baked into the slug (no URL prefix) |\n\n### How it affects preview\n\nCMS admin reads `localeStrategy` and `defaultLocale` to construct preview URLs correctly:\n\n- **prefix-other**: DA document `about-da` → preview opens `/da/about` (strips `-da` suffix, adds `/da/` prefix)\n- **prefix-all**: same but also adds `/en/` for default locale\n- **none**: DA document `about-da` → preview opens `/docs/about-da` (slug used as-is)\n\n### How it affects homepage\n\nFor documents with slug `home` or `home-da` and urlPrefix `/`:\n\n| Strategy | EN homepage | DA homepage |\n|----------|------------|-------------|\n| `prefix-other` | `/` | `/da/` |\n| `prefix-all` | `/en/` | `/da/` |\n| `none` | `/` | `/home-da` |\n\n### Configuration\n\nSet in CMS admin → Site Settings → Language, or in `_data/site-config.json`:\n\n```json\n{\n  \"defaultLocale\": \"en\",\n  \"locales\": [\"en\", \"da\"],\n  \"localeStrategy\": \"prefix-other\"\n}\n```\n\n### For site builders\n\nYour Next.js routing must match the strategy:\n\n**prefix-other** → `app/[locale]/blog/[slug]/page.tsx` with middleware for locale detection\n\n**prefix-all** → same, but default locale also has prefix\n\n**none** → `app/blog/[slug]/page.tsx` where slug includes locale suffix\n",
  "excerpt": "⚠️ CRITICAL: translationGroup is MANDATORY for multilingual sites\n\nIf you are building a site with 2+ languages, every document that has a translation MUST have a translationGroup field. Without it:\n\n- The admin side-by-side editor is broken — editors cannot see EN and DA next to each other\n- The la",
  "seo": {
    "metaTitle": "Internationalization (i18n) — webhouse.app Docs",
    "metaDescription": "Multi-language content with automatic AI translation, locale routing, and hreflang.",
    "keywords": [
      "webhouse",
      "cms",
      "documentation",
      "guides"
    ]
  },
  "createdAt": "2026-03-29T21:41:59.068Z",
  "updatedAt": "2026-03-31T17:11:43.064Z"
}