Cookbook: Multilingual Site
Техническое руководство из исходного проекта Notty. Примеры, параметры и эксплуатационные ограничения.
Set up content localization in Notty CMS with multiple languages, per-field translation, and fallback strategies.
What You'll Build
- Content type with localized fields (title, body) and non-localized fields (slug, cover)
- Content in English and Russian
- API queries with locale filtering
- Translation status tracking
Step 1: Enable i18n on a Schema
// schemas/article.json
{
"kind": "collectionType",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "Article"
},
"options": {
"draftAndPublish": true,
"timestamps": true,
"i18n": {
"enabled": true,
"locales": ["en", "ru"],
"defaultLocale": "en"
}
},
"indexes": [{ "fields": ["slug", "locale"], "type": "unique" }],
"attributes": {
"title": {
"type": "string",
"required": true,
"pluginOptions": { "i18n": { "localized": true } }
},
"slug": {
"type": "string",
"required": true
},
"body": {
"type": "richtext",
"pluginOptions": { "i18n": { "localized": true } }
},
"cover": {
"type": "media",
"allowedTypes": ["images"]
}
}
}
Key decisions:
titleandbodyare localized — each locale has its own valueslugandcoverare not localized — shared across all locales- Composite unique index on
[slug, locale]prevents slug collisions within a locale
Step 2: Create Content in the Default Locale
curl -X POST http://localhost:2102/api/content/article \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Getting Started with Notty",
"slug": "getting-started",
"body": "<p>Welcome to Notty CMS — a modern headless content management system.</p>",
"locale": "en"
}'
Step 3: Add a Translation
Create a localization for the same entry:
curl -X POST http://localhost:2102/api/content/article/1/localizations \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"locale": "ru",
"title": "Начало работы с Notty",
"body": "<p>Добро пожаловать в Notty CMS — современную headless систему управления контентом.</p>"
}'
Non-localized fields (slug, cover) are inherited from the original entry.
Save the returned localization ID from the response. In the examples below, assume the Russian localization was created as entry 2.
Step 4: Query by Locale
Get English Content
curl "http://localhost:2102/api/content/article?locale=en&published=true" \
-H "Authorization: Bearer $TOKEN"
Get Russian Content
curl "http://localhost:2102/api/content/article?locale=ru&published=true" \
-H "Authorization: Bearer $TOKEN"
Get All Localizations for an Entry
curl http://localhost:2102/api/content/article/1/localizations \
-H "Authorization: Bearer $TOKEN"
Returns all locale versions of the entry.
Step 5: Translation Status
Track which translations are up to date:
# Inspect localizations and translation metadata for the document
curl http://localhost:2102/api/content/article/1/localizations \
-H "Authorization: Bearer $TOKEN"
The response includes data.translationMeta, which stores the workflow/status metadata for each locale in the document.
Translation Statuses
| Status | Meaning |
|---|---|
draft |
Translation in progress |
in_review |
Pending review |
approved |
Approved for publication |
published |
Live |
outdated |
Source locale changed — needs update |
Update Translation Status
Use the localization record ID in the path. For example, to update the Russian entry created above:
curl -X PUT http://localhost:2102/api/content/article/2/localizations/status \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"status": "approved",
"notes": "Ready for editorial review"
}'
Step 6: Compare Translations
See what changed between locales:
curl "http://localhost:2102/api/content/article/2/localizations/diff" \
-H "Authorization: Bearer $TOKEN"
The diff endpoint uses the localization entry in the URL and compares it against its source locale automatically.
Frontend Integration
Language Switcher Pattern
// Fetch articles for the current locale
async function getArticles(locale: string) {
const res = await fetch(
`${NOTTY_URL}/api/content/article?locale=${locale}&published=true&populate=cover`,
{ headers: { Authorization: `Bearer ${token}` } }
);
return res.json();
}
// In your Next.js page:
// app/[locale]/page.tsx
export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const { data: articles } = await getArticles(locale);
// Render articles...
}
SEO with hreflang
// Generate alternate links for SEO
async function getAlternateLinks(slug: string) {
const locales = ['en', 'ru'];
return locales.map((locale) => ({
rel: 'alternate',
hrefLang: locale,
href: `https://example.com/${locale}/blog/${slug}`,
}));
}
Best Practices
- Always set
localein queries — otherwise you get all locales mixed together - Use composite unique indexes on
[slug, locale]to allow the same slug in different locales - Only localize user-facing text — keep slugs, IDs, and media references non-localized
- Track translation status — mark translations as
outdatedwhen the source locale changes - Set up a translation workflow — use the admin panel to review and approve translations before publishing
Источник: docs/cookbook/multilingual-site.md. Снимок документации исходного проекта. Технический справочник сохраняет язык оригинала.