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