webhouse.appwebhouse.appdocs

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.

json
// 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

typescript
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:

json
// 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:

  1. Open a document — see the locale badge showing current language
  2. Click "+ Add translation" to create a new locale version
  3. The AI translator auto-translates all fields
  4. Review and publish the translation

Translations appear grouped in the document list and the editor shows a locale switcher.

AI translation

typescript
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 data

Locale routing in Next.js

Use a [locale] route segment:

app/
  [locale]/
    blog/
      [slug]/page.tsx
    page.tsx
  layout.tsx

With a middleware for locale detection:

typescript
// 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

  1. When you create a document, it gets a unique translationGroup UUID
  2. When you create a translation (via admin UI or script), the new document gets the same translationGroup
  3. CMS admin uses this to show locale badges, language switcher, and side-by-side editing

Document structure

json
// 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

  1. Always set locale on every document ("en", "da", etc.)
  2. Always set translationGroup — use crypto.randomUUID() for new documents
  3. Share the same translationGroup across all locale versions of a document
  4. Slug convention: source slug + locale suffix (e.g. hello-worldhello-world-da)
  5. Never duplicate translationGroup across unrelated documents

Pairing script example

typescript
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-other` — URL prefix for non-default locales: `/da/blog/slug`

- 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:

StrategyEN (default) URLDA URLWhen to use
prefix-other/blog/my-post/da/blog/my-postMost sites — clean default URLs, prefix for other locales
prefix-all/en/blog/my-post/da/blog/my-postWhen you want explicit locale in ALL URLs
none/blog/my-post/blog/my-post-daWhen 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 -da suffix, 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 /:

StrategyEN homepageDA 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:

json
{
  "defaultLocale": "en",
  "locales": ["en", "da"],
  "localeStrategy": "prefix-other"
}

For site builders

Your Next.js routing must match the strategy:

prefix-otherapp/[locale]/blog/[slug]/page.tsx with middleware for locale detection

prefix-all → same, but default locale also has prefix

noneapp/blog/[slug]/page.tsx where slug includes locale suffix

Tags:i18nTranslationSEO
Previous
Next.js Patterns
Next
Passkeys & Two-Factor Login
JSON API →Edit on GitHub →