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_enandtitle_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
- In the dashboard: Project Settings → Localization → Add a Locale (e.g.
de). - Create a Pages collection (
pages) with title (Text), slug (Slug from title), body (Rich Text). - 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,
})
Fetch a linked translation without storing both UUIDs:
const germanPage = await noma.content.get("pages", englishUuid, {
state: "published",
translation_locale: "de",
})
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>
)
}
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()
}
}
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 })
})
Full setup: Next.js guide · Nuxt guide
AI translate in the dashboard
Open the English entry → Translations → Translate 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
- Full tutorial on nomacms.com
- Content SDK
- Try NomaCMS (7-day free trial)
Questions welcome in the comments.
Top comments (0)