Skip to content

Multilingual Support

Stelo CMS provides comprehensive multilingual support through a JSON-based localization system that enables you to create content in multiple languages while maintaining type safety and performance.

Stelo CMS uses a field-level localization approach where each content field can contain translations for multiple locales:

// Database schema
model Page {
id String @id @default(cuid())
title Json // { "en": "Welcome", "fr": "Bienvenue", "es": "Bienvenido" }
content Json // Localized rich content
slug Json // { "en": "welcome", "fr": "bienvenue", "es": "bienvenido" }
}
  1. Type Safety: Full TypeScript support for all localized content
  2. Performance: No joins required - all translations in single record
  3. Flexibility: Different content structures per language
  4. Atomic Updates: All translations updated together
  5. Version Control: Complete translation history
src/lib/i18n/config.ts
export const supportedLocales = ['en', 'fr', 'es'] as const;
export type Locale = typeof supportedLocales[number];
export const localeConfig = {
en: {
label: 'English',
flag: 'đŸ‡ș🇾',
direction: 'ltr',
dateFormat: 'MM/dd/yyyy',
currency: 'USD'
},
fr: {
label: 'Français',
flag: 'đŸ‡«đŸ‡·',
direction: 'ltr',
dateFormat: 'dd/MM/yyyy',
currency: 'EUR'
},
es: {
label: 'Español',
flag: 'đŸ‡Ș🇾',
direction: 'ltr',
dateFormat: 'dd/MM/yyyy',
currency: 'EUR'
}
} as const;

To add a new locale to your project:

  1. Update Configuration:
// Add to supportedLocales array
export const supportedLocales = ['en', 'fr', 'es', 'de'] as const;
// Add locale configuration
export const localeConfig = {
// ... existing locales
de: {
label: 'Deutsch',
flag: 'đŸ‡©đŸ‡Ș',
direction: 'ltr',
dateFormat: 'dd.MM.yyyy',
currency: 'EUR'
}
};
  1. Update Zod Schemas:
src/lib/schemas/base.ts
export const localizedStringSchema = z.record(
z.enum(['en', 'fr', 'es', 'de']), // Add new locale
z.string().min(1)
);
  1. Migrate Existing Content:
-- Add default values for existing content
UPDATE pages
SET title = jsonb_set(title, '{de}', title->'en')
WHERE title ? 'en' AND NOT title ? 'de';

Simple text fields are stored as JSON objects with locale keys:

// Creating localized content
const pageData = {
title: {
en: "About Our Company",
fr: "À Propos de Notre Entreprise",
es: "Acerca de Nuestra Empresa"
},
slug: {
en: "about-us",
fr: "a-propos",
es: "acerca-de"
}
};

Rich content includes HTML, images, and metadata:

// Rich content structure
const richContent = {
en: {
body: "<h1>Welcome</h1><p>Our story began...</p>",
excerpt: "Learn about our company history",
featuredImage: "https://storage.com/about-en.jpg",
gallery: ["img1.jpg", "img2.jpg"],
metadata: {
readingTime: 5,
wordCount: 450
}
},
fr: {
body: "<h1>Bienvenue</h1><p>Notre histoire a commencé...</p>",
excerpt: "Découvrez l'histoire de notre entreprise",
featuredImage: "https://storage.com/about-fr.jpg",
gallery: ["img1-fr.jpg", "img2-fr.jpg"],
metadata: {
readingTime: 5,
wordCount: 480
}
}
};

Images and files can be localized by language:

// Localized media structure
const localizedMedia = {
hero: {
en: {
url: "https://storage.com/hero-en.jpg",
alt: "Welcome to our website",
caption: "Our beautiful office in New York"
},
fr: {
url: "https://storage.com/hero-fr.jpg",
alt: "Bienvenue sur notre site",
caption: "Notre beau bureau Ă  Paris"
}
}
};

All API queries support locale parameters:

// Frontend API calls
const page = await trpc.pages.getBySlug.query({
slug: "about-us",
locale: "fr" // Returns French content
});
// Multiple locales
const pageAllLocales = await trpc.pages.getBySlug.query({
slug: "about-us",
includeAllLocales: true // Returns all translations
});

API responses include locale information:

// Single locale response
{
id: "page_123",
slug: "about-us",
title: "À Propos de Notre Entreprise",
content: {
body: "<h1>Bienvenue</h1>...",
excerpt: "Découvrez notre histoire"
},
locale: "fr",
lastModified: "2024-01-15T10:30:00Z"
}
// Multi-locale response
{
id: "page_123",
translations: {
en: {
title: "About Our Company",
content: { ... }
},
fr: {
title: "À Propos de Notre Entreprise",
content: { ... }
}
},
defaultLocale: "en"
}

The CMS interface includes a language switcher for content editing:

// Language switcher component
function LanguageSwitcher({ currentLocale, onLocaleChange }: {
currentLocale: Locale;
onLocaleChange: (locale: Locale) => void;
}) {
return (
<Select value={currentLocale} onValueChange={onLocaleChange}>
{supportedLocales.map(locale => (
<SelectItem key={locale} value={locale}>
{localeConfig[locale].flag} {localeConfig[locale].label}
</SelectItem>
))}
</Select>
);
}

The CMS provides tools for managing translations:

// Shows completion status for each locale
function TranslationStatus({ content }: { content: LocalizedContent }) {
const completionStatus = supportedLocales.map(locale => ({
locale,
complete: Boolean(content[locale]?.title && content[locale]?.body),
percentage: calculateCompletionPercentage(content[locale])
}));
return (
<div className="translation-status">
{completionStatus.map(({ locale, complete, percentage }) => (
<div key={locale} className={`status-${complete ? 'complete' : 'incomplete'}`}>
{localeConfig[locale].flag} {percentage}%
</div>
))}
</div>
);
}
// Copy content from default language to target language
async function copyTranslation(entityId: string, fromLocale: Locale, toLocale: Locale) {
const entity = await prisma.page.findUnique({
where: { id: entityId }
});
if (!entity) throw new Error('Entity not found');
const sourceContent = entity.content[fromLocale];
await prisma.page.update({
where: { id: entityId },
data: {
content: {
...entity.content,
[toLocale]: {
...sourceContent,
// Mark as machine-translated
__metadata: {
translatedFrom: fromLocale,
needsReview: true
}
}
}
}
});
}

Configure Next.js for internationalization:

next.config.js
const nextConfig = {
i18n: {
locales: ['en', 'fr', 'es'],
defaultLocale: 'en',
localeDetection: true
}
};
middleware.ts
import createMiddleware from 'next-intl/middleware';
export default createMiddleware({
locales: ['en', 'fr', 'es'],
defaultLocale: 'en',
// Redirect strategy
localePrefix: 'as-needed' // Only add prefix for non-default locales
});
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
};
app/[locale]/[slug]/page.tsx
interface PageProps {
params: {
locale: Locale;
slug: string;
};
}
export default async function Page({ params }: PageProps) {
const page = await trpc.pages.getBySlug.query({
slug: params.slug,
locale: params.locale
});
return (
<div>
<h1>{page.title}</h1>
<div dangerouslySetInnerHTML={{ __html: page.content.body }} />
</div>
);
}
// Generate static paths for all locales
export async function generateStaticParams() {
const pages = await trpc.pages.getAll.query();
const params = [];
for (const page of pages) {
for (const locale of supportedLocales) {
if (page.title[locale]) {
params.push({
locale,
slug: page.slug[locale]
});
}
}
}
return params;
}
// SEO component with hreflang
function SEOHead({ page, currentLocale }: {
page: LocalizedPage;
currentLocale: Locale;
}) {
return (
<Head>
{/* Current page */}
<link
rel="canonical"
href={`https://example.com/${currentLocale}/${page.slug[currentLocale]}`}
/>
{/* Alternate language versions */}
{supportedLocales.map(locale => (
page.title[locale] && (
<link
key={locale}
rel="alternate"
hrefLang={locale}
href={`https://example.com/${locale}/${page.slug[locale]}`}
/>
)
))}
{/* Default language fallback */}
<link
rel="alternate"
hrefLang="x-default"
href={`https://example.com/${page.slug.en}`}
/>
</Head>
);
}
// Generate multilingual sitemap
export async function generateSitemap() {
const pages = await trpc.pages.getPublished.query();
const urls = [];
for (const page of pages) {
for (const locale of supportedLocales) {
if (page.title[locale] && page.slug[locale]) {
urls.push({
url: `https://example.com/${locale}/${page.slug[locale]}`,
lastModified: page.updatedAt,
alternateRefs: supportedLocales
.filter(l => page.title[l])
.map(l => ({
href: `https://example.com/${l}/${page.slug[l]}`,
hreflang: l
}))
});
}
}
}
return urls;
}
// Locale-aware caching
const cacheKey = `page:${slug}:${locale}`;
const cachedPage = await redis.get(cacheKey);
if (cachedPage) {
return JSON.parse(cachedPage);
}
const page = await prisma.page.findFirst({
where: { slug: { path: [locale], equals: slug } }
});
await redis.setex(cacheKey, 3600, JSON.stringify(page));
-- Indexes for localized queries
CREATE INDEX idx_pages_title_en ON pages USING gin ((title->'en'));
CREATE INDEX idx_pages_title_fr ON pages USING gin ((title->'fr'));
CREATE INDEX idx_pages_slug_en ON pages USING gin ((slug->'en'));
-- Partial index for published content
CREATE INDEX idx_pages_published_locale ON pages (published)
WHERE published = true;
  1. Always provide default language content before adding translations
  2. Use consistent terminology across all languages
  3. Consider cultural differences beyond just language
  4. Plan for text expansion (German ~30% longer than English)
  5. Validate all locales before publishing
  1. Use TypeScript unions for locale types
  2. Validate locale data with Zod schemas
  3. Handle missing translations gracefully
  4. Cache aggressively but invalidate properly
  5. Monitor translation coverage in analytics
  1. Implement proper hreflang tags
  2. Create locale-specific sitemaps
  3. Use local domain structures when possible
  4. Optimize for local search engines
  5. Consider regional keyword variations

This multilingual system provides a robust foundation for international websites while maintaining the performance and type safety that makes Stelo CMS powerful for developers.