Chapter 9: Multi-language Content
Vibe CMS is built from the ground up to support multiple languages and locales. This chapter covers how to set up, manage, and publish content in multiple languages.
Locale Setup
What is a Locale?
A locale represents a specific language and region combination. Vibe CMS uses BCP 47 format for locale codes:
Language code (required): Two-letter ISO 639-1 code
en= Englishfr= Frenches= Spanishde= Germanzh= Chinesear= Arabic
Region code (optional): Two-letter ISO 3166-1 code
US= United StatesGB= United KingdomCA= CanadaCN= ChinaTW= Taiwan
Examples:
en= English (generic)en-US= English (United States)en-GB= English (United Kingdom)zh-CN= Chinese (Simplified, China)zh-TW= Chinese (Traditional, Taiwan)pt-BR= Portuguese (Brazil)pt-PT= Portuguese (Portugal)
Creating Locales
Via Admin Dashboard:
- Navigate to Project Settings → Languages
- Click Add Language
- Select from common locales or enter custom BCP 47 code
- Set a display name (e.g., "English (US)")
- Optionally set as default locale
- Click Create
Via API:
const cms = createVibeCMS({ projectId: 'xxx' });
// Create a new locale
await cms.locales.create({
locale_code: 'es-MX',
display_name: 'Spanish (Mexico)',
is_default: false,
is_active: true
});
Via MCP Server:
vibe-cms-mcp locale create
--locale-code es-MX
--display-name "Spanish (Mexico)"
Default Locale
Every project must have exactly one default locale. The default locale:
- Is required for all content items
- Used as fallback when content unavailable in requested locale
- Cannot be deleted while it's the default
- Pre-populated when creating new content items
Changing default locale:
await cms.locales.setDefault('fr-FR');
Supported Locales List
Vibe CMS provides a comprehensive list of 60+ supported locales. Common ones:
European:
en-US,en-GB,en-CA,en-AUfr-FR,fr-CAes-ES,es-MX,es-USde-DE,de-CH,de-ATit-IT,pt-PT,pt-BR
Asian:
zh-CN(Simplified Chinese)zh-TW(Traditional Chinese)ja-JP,ko-KR,th-TH,vi-VN
Middle Eastern & Indian:
ar-SA(Arabic)he-IL(Hebrew)hi-IN,ta-IN,te-IN
Retrieving supported locales:
const locales = await cms.locales.getSupportedLocales();
console.log(locales);
// Output:
// {
// 'en-US': 'English (United States)',
// 'fr-FR': 'French (France)',
// 'es-ES': 'Spanish (Spain)',
// ...
// }
Translation Workflow
Content Item Translations
Each content item can have translations in multiple locales. The data structure:
interface ContentItem {
id: string; // Unique content item ID
collection_id: string;
version_id: string; // Current draft version
translations: {
[locale: string]: { // Key is locale code (e.g., 'en-US', 'fr-FR')
locale: string;
data: { // Content data
[fieldName: string]: any; // Fields defined in collection schema
};
status: 'draft' | 'published' | 'archived';
}
}
}
Creating Content in Multiple Languages
Step 1: Create content item in default locale
const item = await cms.collection('blog-posts').create({
title: 'My Blog Post',
content: 'This is the English content...',
slug: 'my-blog-post'
});
// Returns item ID, content in default locale
Step 2: Add translations
// Add French translation
await cms.collection('blog-posts').id(item.id).addTranslation('fr-FR', {
title: 'Mon Article de Blog',
content: 'Ceci est le contenu en français...',
slug: 'mon-article-de-blog'
});
// Add Spanish translation
await cms.collection('blog-posts').id(item.id).addTranslation('es-ES', {
title: 'Mi Artículo de Blog',
content: 'Este es el contenido en español...',
slug: 'mi-articulo-de-blog'
});
Step 3: Publish when all translations ready
// Publish all translations at once
await cms.collection('blog-posts').id(item.id).publish();
Editing Translations
// Update only the French translation
await cms.collection('blog-posts')
.id(item.id)
.updateTranslation('fr-FR', {
title: 'Mon Article Mis à Jour'
});
Querying by Locale
// Get content in specific locale
const frenchPost = await cms.collection('blog-posts')
.slug('my-blog-post')
.locale('fr-FR');
// Get all posts in French
const frenchPosts = await cms.collection('blog-posts')
.locale('fr-FR')
.all();
// Get first item in German
const germanContent = await cms.collection('landing-hero')
.locale('de-DE')
.first();
Locale Fallback Strategy
What is Fallback?
Locale fallback defines what happens when requested content isn't available in the requested locale. This provides graceful degradation and ensures users always see content.
Fallback Chain
Vibe CMS uses a hierarchical fallback chain:
- Requested locale (e.g.,
fr-CA) - Language base (e.g.,
frif requestedfr-CA) - Project default locale (e.g.,
en-US) - Global fallback (always
en-US)
Example:
User requests: fr-CA (French Canadian)
Content available in: en-US, fr-FR
Fallback chain:
1. Try fr-CA (not available) ✗
2. Try fr (maps to fr-FR) ✓ Use French content
Another example:
User requests: pt-PT (Portuguese Portugal)
Content available in: en-US, pt-BR
Fallback chain:
1. Try pt-PT (not available) ✗
2. Try pt (maps to pt-BR) ✓ Use Brazilian Portuguese
Configuring Fallback
In project settings:
await cms.projects.updateSettings('project-id', {
locale_fallback: {
enabled: true,
strategy: 'hierarchical', // or 'explicit'
explicit_map: {
'fr-CA': 'fr-FR', // French (Canada) → French (France)
'pt-PT': 'pt-BR', // Portuguese → Brazilian Portuguese
'zh-HK': 'zh-TW' // Hong Kong → Traditional Chinese
}
}
});
SDK Fallback Handling
const cms = createVibeCMS({
projectId: 'xxx',
fallbackLocale: 'en-US', // Default fallback
fallbackChain: ['fr-CA', 'fr', 'en-US'] // Custom chain
});
// SDK automatically uses fallback if requested locale unavailable
const content = await cms.collection('posts')
.slug('my-post')
.locale('fr-CA'); // Returns fr-FR or en-US if fr-CA unavailable
Language-Specific Fields
Some content might need different field values per language.
Example: Slug Translation
URL slugs should be localized:
English (en-US): /blog/my-post
French (fr-FR): /blog/mon-article
German (de-DE): /blog/mein-artikel
In collection schema:
{
name: 'slug',
type: 'text',
translatable: true, // Allow per-locale values
unique_per_locale: true, // Each locale has unique slug
}
In content:
await cms.collection('blog-posts').create({
// These fields vary per locale
title: 'My Post',
slug: 'my-post',
content: '...'
});
await cms.collection('blog-posts').id(item.id).addTranslation('fr-FR', {
title: 'Mon Article',
slug: 'mon-article', // Different slug per locale
content: '...'
});
Non-Translatable Fields
Some fields should be shared across all locales:
{
name: 'featured_image',
type: 'file',
translatable: false, // Same image for all languages
}
// Set on default locale, used by all locales
await cms.collection('blog-posts').create({
featured_image: 'image-asset-id',
title: 'English title'
});
// French translation
await cms.collection('blog-posts').id(item.id).addTranslation('fr-FR', {
title: 'Titre français'
// featured_image inherited from default locale
});
Content Publishing & Locales
Publishing All Translations at Once
// Publish entire content item (all locales)
await cms.collection('blog-posts').id(item.id).publish();
Publishing Specific Locales
// Publish only English
await cms.collection('blog-posts')
.id(item.id)
.locale('en-US')
.publish();
// Can have different publish status per locale
// en-US = published
// fr-FR = draft
// de-DE = published
Check Translation Completeness
const item = await cms.collection('blog-posts').id(item.id);
console.log(item.translations);
// {
// 'en-US': { status: 'published', ... },
// 'fr-FR': { status: 'draft', ... },
// 'de-DE': { status: 'draft', ... }
// }
// Get publishing status summary
const status = await cms.collection('blog-posts')
.id(item.id)
.getTranslationStatus();
console.log(status);
// {
// total_locales: 3,
// locales_with_content: 3,
// published_locales: 1,
// draft_locales: 2,
// publication_ready: false // Not all locales published
// }
RTL (Right-to-Left) Language Support
Vibe CMS supports right-to-left languages:
- Arabic (
ar-*) - Hebrew (
he-IL) - Farsi/Persian (
fa-IR) - Urdu (
ur-PK)
RTL Content Handling
The SDK automatically detects RTL locales:
const locale = 'ar-SA'; // Arabic (Saudi Arabia)
const isRTL = cms.isRTLLocale(locale); // true
const direction = cms.getLocaleDirection(locale); // 'rtl'
Frontend RTL Implementation
In Astro:
---
const currentLocale = 'ar-SA';
const isRTL = ['ar', 'he', 'fa', 'ur'].some(lang =>
currentLocale.startsWith(lang)
);
---
<html dir={isRTL ? 'rtl' : 'ltr'} lang={currentLocale}>
<body>
{/* Content automatically adapts to RTL */}
</body>
</html>
CSS for RTL:
/* Use logical CSS properties */
body {
margin-inline-start: 1rem; /* Right margin in RTL, left in LTR */
padding-inline-end: 1rem; /* Left padding in RTL, right in LTR */
text-align: start; /* Right-aligned in RTL, left in LTR */
}
Auto-Translation (AI-Powered)
Vibe CMS includes optional AI-powered translation:
Enable Auto-Translation
await cms.projects.enableAutoTranslation('project-id', {
provider: 'openai', // or 'anthropic'
model: 'gpt-4',
auto_translate_fields: ['title', 'description', 'content'],
skip_fields: ['slug'], // Never auto-translate slugs
});
Using Auto-Translation
// Create content in English
const item = await cms.collection('blog-posts').create({
title: 'My Blog Post',
content: 'This is the English content...'
});
// Auto-translate to French
await cms.collection('blog-posts')
.id(item.id)
.autoTranslate({
target_locales: ['fr-FR', 'de-DE', 'es-ES'],
provider: 'openai'
});
// Translations now in draft status - review before publishing
Manual Review Workflow
// Get auto-translated draft
const frenchDraft = await cms.collection('blog-posts')
.id(item.id)
.locale('fr-FR');
// Review translation
console.log(frenchDraft.data.title);
// "Mon Article de Blog" (auto-translated)
// Edit if needed
await cms.collection('blog-posts')
.id(item.id)
.updateTranslation('fr-FR', {
title: 'Mon Article de Blog (Titre Révisé)' // Corrected
});
// Then publish
await cms.collection('blog-posts').id(item.id).locale('fr-FR').publish();
Multi-Region Content Strategies
Strategy 1: Single Locale per Region
One content version serves entire region:
Europe: en-GB
North America: en-US
Latin America: es-MX
Asia: zh-CN
Pros: Simple, scalable
Cons: Less cultural customization
Strategy 2: Locale + Content Variants
Multiple locales with region-specific content:
es-ES (Spanish Spain) - Use Spain address format
es-MX (Spanish Mexico) - Use Mexico address format
Field schema:
{
name: 'region_code', // Separate from locale
type: 'select',
options: ['us', 'eu', 'apac']
}
Implementation:
// Get content for Mexican users (Spanish language, regional formatting)
const localizedContent = await cms.collection('guides')
.filter({
locale: 'es-MX',
region_code: 'latam'
})
.first();
Strategy 3: Fallback Hierarchy
Use fallback for graceful degradation:
Requested: de-CH (German Switzerland)
Available: de-DE (German Germany)
Fallback: de-DE auto-selected
Configure:
await cms.projects.updateSettings('project-id', {
locale_fallback: {
explicit_map: {
'de-CH': 'de-DE', // Swiss German → German
'en-NZ': 'en-GB', // New Zealand → British English
'pt-AO': 'pt-PT' // Angola → Portugal Portuguese
}
}
});
Best Practices
Translation Guidelines
Always translate all key fields
- Never publish incomplete translations
- Use draft status while translating
Maintain consistent terminology
- Create glossary for product terms
- Share with all translators
Test in actual context
- Preview on staging site
- Check for UI overflow in different languages
Use professional translators
- Machine translation for quick drafts only
- Professional review before publishing
- Native speakers for RTL languages
Content Organization
Plan locales upfront
- Decide target markets
- Create all locales before content
Translation workflow
Create English → Auto-translate draft → Professional review → PublishManage complexity
- Start with 2-3 core locales
- Expand gradually
- Reuse existing translations as templates
Performance Considerations
// Efficient locale querying
// Bad: Fetch all locales then filter
const allItems = await cms.collection('posts').all();
const frenchItems = allItems.filter(item => item.locale === 'fr-FR');
// Good: Query specific locale
const frenchItems = await cms.collection('posts')
.locale('fr-FR')
.all();
Troubleshooting
Missing Translation Falls Back Too Far
Problem: Requesting fr-CA falls back to en-US instead of fr-FR
Solution: Configure explicit fallback mapping
await cms.projects.updateSettings('project-id', {
locale_fallback: {
explicit_map: {
'fr-CA': 'fr-FR' // Canadian French → France French
}
}
});
Translation Status Confusion
Problem: Not sure which translations are published
Solution: Use translation status API
const status = await cms.collection('posts')
.id(item.id)
.getTranslationStatus();
console.log(status);
// Shows which locales are published vs draft
RTL Content Display Issues
Problem: Arabic text displays left-to-right
Solution: Set HTML dir attribute and use logical CSS
<html dir="rtl" lang="ar-SA">
<!-- CSS with logical properties -->
</html>
Next Steps
Now that you understand multi-language content:
- Set up locales for your target markets
- Create content in your default locale first
- Add translations using auto-translation or professional translators
- Configure locale fallback for graceful degradation
- Test RTL languages on actual devices
Need help? Join our Discord community or check the Locales API Reference.