DEV Community

Raşit
Raşit

Posted on • Originally published at nomacms.com

Multilingual sites with a headless CMS: locales, translation groups, and AI translate

Originally published on nomacms.com. I work on NomaCMS.

Most i18n CMS guides stop at field-level vs document-level locales. Fair comparison, but it skips the boring parts: adding languages to a project, keeping translations linked, fetching the right locale in Next.js, and refreshing cached pages when someone publishes.

This walkthrough covers that stack with NomaCMS: project locales, translation groups, SDK fetch-by-locale, dashboard AI translate, and a webhook hook for revalidation.

Document-level locales vs field-level

Two common patterns:

  • Field-level: one entry, fields like title_en and title_de
  • Document-level: one entry per language, linked as translations

NomaCMS uses document-level locales. Each entry has a locale code. Related translations share a translation group, so your app can swap languages or call translation_locale in the SDK without hardcoding UUID maps.

That fits marketing sites well: editors work in one language at a time, publish state stays per locale, and the frontend just passes locale into content.list or content.get.

Setup: locales and a Pages collection

  1. In the dashboard: Project Settings → Localization → Add a Locale (e.g. de).
  2. Create a Pages collection (pages) with title (Text), slug (Slug from title), body (Rich Text).
  3. Create and publish an English page.

Each locale is its own row. English and German are separate entries, not duplicate fields.

When you create via API/SDK, pass locale on the body. Omit it and the API falls back to the project default.

Translation groups

Open the English entry. With multiple locales configured, the sidebar shows Translations.

From the dialog you can:

  • Translate with AI: draft in the target locale, translate text fields, copy media/relations, generate slug, link the group
  • Create: blank draft in that locale, linked
  • Select: link an existing entry in the same collection

Link from code when you need to:

import { createClient } from "@nomacms/js-sdk"

const noma = createClient({
  projectId: process.env.NOMA_PROJECT_ID!,
  apiKey: process.env.NOMA_API_KEY!,
})

await noma.content.linkTranslation("pages", englishUuid, {
  translation_entry_uuid: germanUuid,
})
Enter fullscreen mode Exit fullscreen mode

Fetch a linked translation without storing both UUIDs:

const germanPage = await noma.content.get("pages", englishUuid, {
  state: "published",
  translation_locale: "de",
})
Enter fullscreen mode Exit fullscreen mode

Both entries must be in the same collection with different locales.

Fetch by locale in Next.js

Keep the API key on the server. Minimal list page with a [locale] segment:

// app/[locale]/pages/page.tsx
import { createClient } from "@nomacms/js-sdk"

type Props = { params: Promise<{ locale: string }> }

export default async function PagesIndex({ params }: Props) {
  const { locale } = await params
  const noma = createClient({
    projectId: process.env.NOMA_PROJECT_ID!,
    apiKey: process.env.NOMA_API_KEY!,
  })

  const result = await noma.content.list("pages", {
    state: "published",
    locale,
    sort: "created_at:desc",
  })

  const pages = "data" in result ? result.data : result

  return (
    <ul>
      {pages.map((page) => (
        <li key={page.uuid}>
          <a href={`/${locale}/pages/${page.uuid}`}>
            {String(page.fields?.title ?? page.uuid)}
          </a>
        </li>
      ))}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

Language switcher on a detail page:

// app/[locale]/pages/[id]/page.tsx
import { notFound } from "next/navigation"
import { createClient } from "@nomacms/js-sdk"

type Props = { params: Promise<{ locale: string; id: string }> }

export default async function PageDetail({ params }: Props) {
  const { locale, id } = await params
  const noma = createClient({
    projectId: process.env.NOMA_PROJECT_ID!,
    apiKey: process.env.NOMA_API_KEY!,
  })

  try {
    const page = await noma.content.get("pages", id, {
      state: "published",
      locale,
    })

    let alternateHref: string | null = null
    try {
      const alt = await noma.content.get("pages", id, {
        state: "published",
        translation_locale: locale === "en" ? "de" : "en",
      })
      alternateHref = `/${alt.locale}/pages/${alt.uuid}`
    } catch {
      // no linked translation
    }

    return (
      <article>
        <h1>{String(page.fields?.title ?? "")}</h1>
        {alternateHref && <p><a href={alternateHref}>Switch language</a></p>}
      </article>
    )
  } catch {
    notFound()
  }
}
Enter fullscreen mode Exit fullscreen mode

Nuxt? Same locale param in a Nitro route:

// server/api/pages/[locale].get.ts
export default defineEventHandler(async (event) => {
  const locale = getRouterParam(event, "locale")
  const noma = useNomaCMSServerClient()
  return noma.content.list("pages", { state: "published", locale })
})
Enter fullscreen mode Exit fullscreen mode

Full setup: Next.js guide · Nuxt guide

AI translate in the dashboard

Open the English entry → TranslationsTranslate with AI next to de.

The UI streams progress field by field. It creates a draft (for regular collections), links the translation group, translates text / longtext / richtext while keeping HTML structure, copies media and relations, and generates slugs from translated titles.

Review, edit, publish. Dashboard-only feature, not a public API.

Webhooks for revalidation

When an editor publishes in any locale, poke Next.js with a content.published webhook. Register from Project Settings → Webhooks or the SDK, then verify X-Webhook-Signature (HMAC-SHA256 of the body) in a Route Handler and call revalidatePath. Webhook docs

Links

Questions welcome in the comments.

Top comments (0)