Building a CMS Translation Pipeline: Developer's Guide to i18n Architecture
While project managers focus on workflow coordination, developers face the technical challenge of building systems that handle multilingual content efficiently. A well-architected translation pipeline reduces manual work, prevents data loss, and scales with your content volume.
This guide covers the technical implementation side: API integrations, automated workflows, and database design patterns that make CMS localization manageable for development teams.
Database Schema Considerations for Multilingual Content
Your database design determines how smoothly translations flow through your system. Two main patterns dominate CMS internationalization:
Separate tables per language (WordPress WPML approach):
CREATE TABLE posts_en (
id INT PRIMARY KEY,
title VARCHAR(255),
content TEXT,
slug VARCHAR(255)
);
CREATE TABLE posts_es (
id INT PRIMARY KEY,
title VARCHAR(255),
content TEXT,
slug VARCHAR(255),
source_id INT -- references posts_en.id
);
Single table with language columns (more common in headless CMS):
CREATE TABLE posts (
id INT PRIMARY KEY,
language_code VARCHAR(5),
title VARCHAR(255),
content TEXT,
slug VARCHAR(255),
translation_group_id INT
);
The single-table approach scales better with multiple languages and simplifies queries, but requires careful indexing on language_code and translation_group_id.
Automated Export/Import Workflows
Manual file exports create bottlenecks. Most translation management systems (TMS) offer APIs that integrate directly with your CMS.
Contentful + Phrase Integration
// Export content for translation
const contentful = require('contentful-management');
const phrase = require('phrase-api');
async function exportForTranslation(entryId, targetLocale) {
const entry = await contentfulClient.getEntry(entryId);
// Extract translatable fields
const translatable = {
title: entry.fields.title['en-US'],
body: entry.fields.body['en-US'],
metaDescription: entry.fields.metaDescription['en-US']
};
// Create translation job in Phrase
const job = await phraseClient.createJob({
name: `Entry ${entryId} - ${targetLocale}`,
sourceLocale: 'en',
targetLocales: [targetLocale],
content: translatable
});
return job.id;
}
Strapi Custom Plugin
Strapi's plugin system lets you build translation workflows directly into the admin interface:
// strapi-plugin-translations/server/controllers/translation.js
module.exports = {
async exportContent(ctx) {
const { contentType, id, targetLocale } = ctx.request.body;
const entity = await strapi.entityService.findOne(
contentType,
id,
{ populate: '*' }
);
// Generate XLIFF format
const xliff = generateXLIFF(entity, targetLocale);
// Send to translation service
const jobId = await translationService.createJob(xliff);
ctx.body = { success: true, jobId };
}
};
Handling Complex Content Structures
Modern CMS platforms use nested objects, arrays, and references that don't translate cleanly to flat key-value pairs.
JSON Field Translation
// Original content
const content = {
hero: {
title: "Welcome to our platform",
subtitle: "Build amazing applications",
cta: { text: "Get Started", url: "/signup" }
},
features: [
{ name: "Fast", description: "Lightning quick" },
{ name: "Secure", description: "Bank-grade security" }
]
};
// Flatten for translation
function flattenForTranslation(obj, prefix = '') {
const flattened = {};
Object.keys(obj).forEach(key => {
const value = obj[key];
const newKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'string') {
flattened[newKey] = value;
} else if (Array.isArray(value)) {
value.forEach((item, index) => {
Object.assign(flattened, flattenForTranslation(item, `${newKey}.${index}`));
});
} else if (typeof value === 'object') {
Object.assign(flattened, flattenForTranslation(value, newKey));
}
});
return flattened;
}
API Design for Multilingual Content
Your API structure affects how frontend applications consume translated content. Consider language-aware endpoints:
// Language-specific routes
app.get('/api/:lang/posts', getPosts);
app.get('/api/:lang/posts/:slug', getPost);
// Or header-based
app.get('/api/posts', (req, res) => {
const lang = req.headers['accept-language'] || 'en';
const posts = getPostsByLanguage(lang);
res.json(posts);
});
// GraphQL with locale argument
const typeDefs = `
type Query {
posts(locale: String = "en"): [Post]
post(slug: String!, locale: String = "en"): Post
}
type Post {
id: ID!
title: String!
content: String!
slug: String!
locale: String!
}
`;
Translation Memory Integration
Translation memories (TM) reduce costs by reusing previous translations. Most TMS platforms provide APIs to query existing translations:
async function checkTranslationMemory(sourceText, sourceLang, targetLang) {
const response = await fetch(`${TM_API_URL}/matches`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TM_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
source_text: sourceText,
source_language: sourceLang,
target_language: targetLang,
min_match_percentage: 85
})
});
const matches = await response.json();
return matches.length > 0 ? matches[0].target_text : null;
}
Webhook-Driven Updates
Set up webhooks to automatically import completed translations:
// Express webhook handler
app.post('/webhooks/translation-complete', async (req, res) => {
const { jobId, targetLocale, translations } = req.body;
try {
// Validate webhook signature
if (!validateSignature(req)) {
return res.status(401).send('Invalid signature');
}
// Import translations back to CMS
await importTranslations(jobId, targetLocale, translations);
// Trigger cache invalidation
await invalidateCache(`/api/${targetLocale}/*`);
res.status(200).send('OK');
} catch (error) {
console.error('Translation import failed:', error);
res.status(500).send('Import failed');
}
});
Performance Considerations
Multilingual sites can quickly become slow without proper optimization:
-
Database indexing: Index on
language_codeandtranslation_group_id - CDN configuration: Serve language-specific content from edge locations
- Lazy loading: Only load the active language's content
- Caching strategy: Cache per language and invalidate selectively
// Redis caching by language
const cacheKey = `posts:${language}:${page}`;
const cached = await redis.get(cacheKey);
if (!cached) {
const posts = await db.getPosts({ language, page });
await redis.setex(cacheKey, 3600, JSON.stringify(posts));
return posts;
}
return JSON.parse(cached);
Testing Multilingual Features
Automated testing becomes crucial with multiple languages:
// Jest test for translation endpoints
describe('Multilingual API', () => {
test('returns content in requested language', async () => {
const response = await request(app)
.get('/api/posts')
.set('Accept-Language', 'es');
expect(response.status).toBe(200);
expect(response.body[0].locale).toBe('es');
expect(response.body[0].title).not.toContain('Hello'); // English word
});
test('falls back to default language', async () => {
const response = await request(app)
.get('/api/posts')
.set('Accept-Language', 'unsupported-lang');
expect(response.body[0].locale).toBe('en');
});
});
Next Steps
The technical foundation described here supports the project management practices outlined in M21Global's CMS localization guide. Focus on building automated workflows early. Manual processes don't scale, and technical debt in internationalization systems is expensive to fix later.
Start with a solid database schema, add API endpoints that handle language parameters cleanly, and integrate with translation management platforms through webhooks rather than file uploads. Your future self (and your project managers) will thank you.
Top comments (0)