Blog
Multilingual sites with a headless CMS: locales, translation groups, and AI translate
Most multilingual CMS guides compare field-level vs document-level locales and stop there. That helps you pick a model, but it does not cover the day-to-day work: adding languages to a project, keeping translations linked, fetching the right locale in your frontend, and refreshing pages when editors publish.
This tutorial walks through that stack in NomaCMS. You will configure locales, link entries in translation groups, query by locale with the JavaScript SDK, use one-click AI translate in the dashboard, and wire webhooks so Next.js can revalidate on publish.
Why multilingual is a schema decision
Headless CMS products handle languages in two common ways:
- Field-level locales: one entry holds
title_en,title_de, and so on. - Document-level locales: each language is its own entry, linked as translations of the same content.
NomaCMS uses document-level locales. Every entry has a locale code. Related translations share a translation group so your app can jump from the English post to the German post, or fetch a linked row with translation_locale in the SDK.
That shape fits marketing sites and product docs well: editors work on one language at a time, publish states stay independent per locale, and your frontend passes a locale string into content.list or content.get.
What you will build
- A Pages collection with English and German content
- Project locales configured in the dashboard
- Linked translation groups between entries
- A Next.js route that lists pages by locale (with a Nuxt snippet if you prefer Vue)
- A webhook that triggers revalidation when content is published
You need a NomaCMS account (app.nomacms.com) and a Next.js or Nuxt app.
1. Configure locales on the project
Every project starts with a default locale (usually en). Add more from the dashboard:
- Open your project and go to Project Settings.
- Click Localization in the sidebar.
- Under Add a Locale, pick a code (for example
de) and click Add. - Optionally click Set as default on another locale if that should be the fallback.
The Available Locales table lists every code on the project. You cannot delete the default locale, but you can remove others when they are no longer needed.
When you create entries through the API or SDK, pass locale on the body. If you omit it, the API uses the project default locale.
2. Model a collection for translated pages
- Create a collection called Pages with slug
pages. - Add fields:
- title (Text)
- slug (Slug, source field: title)
- body (Rich Text)
- Create an English page, save, and Publish when ready.
Each locale gets its own row in the collection. The English and German versions are separate entries, not duplicate fields on one row.
3. Link entries in a translation group
Open the English entry in the editor. When the project has more than one locale, the sidebar shows a Translations button.
Click Translations to open the dialog. For each locale you will see one of these states:
- Current: the entry you are editing
- Entry #123: a linked translation you can open
- No translation: nothing linked yet
To add German content you have three options:
- Translate with AI: creates a new draft entry in the target locale, translates text fields, copies media and relations, generates the slug from the translated title, and links the group automatically
- Create: creates a blank draft in that locale and links it
- Select: link an existing entry in the same collection that already uses the target locale
For manual work, click Create, open the new entry, write the German copy, save, and publish when ready.
You can also link two existing entries from code:
await noma.content.linkTranslation("pages", englishUuid, {
translation_entry_uuid: germanUuid,
})Both entries must live in the same collection and use different locales. The SDK method requires an API key with the update ability.
To read a linked translation without knowing its UUID:
const germanPage = await noma.content.get("pages", englishUuid, {
state: "published",
translation_locale: "de",
})4. Fetch content by locale in Next.js
Install the SDK on the server and keep credentials out of the browser. See the Next.js guide for the full setup.
Add a dynamic locale segment and pass it to the SDK:
// app/[locale]/pages/page.tsx
import { getNomaCMSServerClient } from "@/lib/nomacms"
type Props = { params: Promise<{ locale: string }> }
export default async function PagesIndex({ params }: Props) {
const { locale } = await params
const noma = getNomaCMSServerClient()
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>
)
}On a detail page, pass both locale and translation_locale when you want a language switcher:
// app/[locale]/pages/[id]/page.tsx
import { notFound } from "next/navigation"
import { getNomaCMSServerClient } from "@/lib/nomacms"
type Props = { params: Promise<{ locale: string; id: string }> }
export default async function PageDetail({ params }: Props) {
const { locale, id } = await params
const noma = getNomaCMSServerClient()
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 in that locale
}
return (
<article>
<h1>{String(page.fields?.title ?? "")}</h1>
{alternateHref && (
<p>
<a href={alternateHref}>Switch language</a>
</p>
)}
{/* Render body from page.fields */}
</article>
)
} catch {
notFound()
}
}Map / to your default locale in middleware or a redirect if you want clean URLs like /de/pages.
Nuxt variant
The same locale parameter works 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,
sort: "created_at:desc",
})
})See the Nuxt guide for runtime config and the server client helper.
5. Translate with AI from the dashboard
For the first German version, open the English entry, click Translations, and click Translate with AI next to de.
The dashboard streams progress as it works through translatable fields (text, longtext, richtext). It:
- Creates a new entry in the target locale (as a draft for regular collections)
- Links it to the source entry in the same translation group
- Translates text content while preserving HTML structure in rich text fields
- Copies non-text values (numbers, dates, media, relations) as-is
- Generates slug fields from the translated source text
Review the draft, edit anything the model missed, then publish when ready. AI translate runs in the dashboard only; it is not a public API route.
Singleton collections behave slightly differently: new translations publish immediately, and the API may auto-link singleton entries across locales when you create them.
6. Revalidate on publish with webhooks
Static and cached Next.js pages need a nudge when an editor publishes in any locale. Register a webhook for content.published (and optionally content.updated).
From Project Settings → Webhooks in the dashboard, or via the SDK with an API key that has the admin ability:
await noma.webhooks.create({
name: "Revalidate pages",
url: "https://your-site.com/api/revalidate",
secret: "use-a-long-random-secret",
events: ["content.published"],
sources: ["cms", "api"],
payload: true,
status: true,
})Add a Route Handler that verifies the signature and calls Next.js revalidation:
// app/api/revalidate/route.ts
import { createHmac, timingSafeEqual } from "crypto"
import { revalidatePath } from "next/cache"
import { NextResponse } from "next/server"
function verifySignature(body: string, signature: string | null, secret: string) {
if (!signature) return false
const expected = createHmac("sha256", secret).update(body).digest("hex")
try {
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
} catch {
return false
}
}
export async function POST(request: Request) {
const secret = process.env.NOMA_WEBHOOK_SECRET!
const rawBody = await request.text()
const signature = request.headers.get("x-webhook-signature")
if (!verifySignature(rawBody, signature, secret)) {
return NextResponse.json({ ok: false }, { status: 401 })
}
const payload = JSON.parse(rawBody)
const locale = payload.content_entry?.locale as string | undefined
// Revalidate locale-specific paths; adjust to match your routes
if (locale) {
revalidatePath(`/${locale}/pages`)
}
revalidatePath("/")
return NextResponse.json({ revalidated: true })
}Check Webhook Logs in the dashboard if deliveries fail. URLs must be HTTPS and publicly reachable.
What to explore next
- Content SDK reference for
locale,translation_locale, andlinkTranslation - Translations API for REST details
- Project auth if your multilingual site also needs end-user signup and login in the same platform
Every NomaCMS plan includes a 7-day free trial.