Skip to content

i18n Workflow

Contentrain handles internationalization through per-locale JSON files and dictionary models. Each locale gets its own file — any platform that reads JSON can load the right locale. The TypeScript SDK adds a convenience API, but the files are the source of truth.

How Contentrain Handles i18n

Every piece of content in Contentrain can be localized. The system stores one file per locale per model, with the same keys across all locales.

Locale configuration

Locales are defined in .contentrain/config.json:

json
{
  "locales": [
    { "code": "en", "label": "English", "default": true },
    { "code": "tr", "label": "Turkish" },
    { "code": "de", "label": "German" }
  ]
}

The default locale is the source of truth. All other locales are translations of the default.

Locale strategies

Each model can use a different file organization strategy:

StrategyFile patternBest for
file (default){model-id}/{locale}.jsonMost models
suffix{model-id}.{locale}.jsonSingle-directory layouts
directory{locale}/{model-id}.jsonLocale-first organization
none{model-id}/data.jsonSingle-language projects

For document models, the patterns use markdown files:

StrategyFile pattern
file{slug}/{locale}.md
suffix{slug}.{locale}.md
directory{locale}/{slug}.md

Dictionary Model for UI Strings

Dictionaries are the primary model for UI strings — button labels, form messages, navigation items, error messages.

Structure

A dictionary is a flat key-value JSON file where all values are strings:

json
{
  "auth.login.button": "Sign in",
  "auth.login.error": "Invalid email or password",
  "auth.register.button": "Create account",
  "nav.home": "Home",
  "nav.blog": "Blog",
  "nav.pricing": "Pricing",
  "cta.get_started": "Get started free",
  "cta.learn_more": "Learn more"
}

TIP

Keys are semantic dot-separated addresses. Use a consistent naming convention: {section}.{subsection}.{element}. Keys are the identities — no id or slug field.

Creating a dictionary

"Create a dictionary model called ui-labels for all UI strings"

The agent creates the model and populates it with extracted strings. Each locale gets its own file:

.contentrain/content/ui/ui-labels/
  en.json    # Source locale
  tr.json    # Turkish translation
  de.json    # German translation

Querying dictionaries

vue
<script setup lang="ts">
// In a Nuxt server route:
import { dictionary } from '#contentrain'
const labels = dictionary('ui-labels').locale('en').get()
// labels['auth.login.button'] → "Sign in"

// Single key:
const loginBtn = dictionary('ui-labels').locale('en').get('auth.login.button')
// loginBtn → "Sign in"
</script>
tsx
import { dictionary } from '#contentrain'

// All labels:
const labels = dictionary('ui-labels').locale('en').get()

// Single key:
const loginBtn = dictionary('ui-labels').locale('en').get('auth.login.button')
astro
---
import { dictionary } from '#contentrain'

const locale = Astro.currentLocale ?? 'en'
const t = dictionary('ui-labels').locale(locale).get()
---

<button>{t['auth.login.button']}</button>

Collection and Singleton Locale Files

Collections and singletons also support per-locale files.

Collection example

A testimonials collection with i18n enabled:

.contentrain/content/marketing/testimonials/
  en.json
  tr.json

en.json:

json
{
  "a1b2c3d4e5f6": {
    "author": "Jane Smith",
    "role": "CTO at Acme",
    "quote": "Contentrain transformed our content workflow"
  },
  "f6e5d4c3b2a1": {
    "author": "John Doe",
    "role": "Lead Developer",
    "quote": "Finally, content that developers can manage"
  }
}

tr.json:

json
{
  "a1b2c3d4e5f6": {
    "author": "Jane Smith",
    "role": "Acme CTO",
    "quote": "Contentrain içerik iş akışımızı dönüştürdü"
  },
  "f6e5d4c3b2a1": {
    "author": "John Doe",
    "role": "Baş Geliştirici",
    "quote": "Sonunda, geliştiricilerin yönetebileceği içerik"
  }
}

INFO

Entry IDs are identical across locales. Only field values change. The author field may or may not be translated depending on context.

Singleton example

A hero-section singleton:

.contentrain/content/marketing/hero-section/
  en.json
  tr.json

en.json:

json
{
  "title": "Build faster with AI-powered content",
  "subtitle": "Ship your next project in days, not weeks",
  "cta_text": "Get started free"
}

tr.json:

json
{
  "title": "Yapay zeka destekli içerikle daha hızlı geliştirin",
  "subtitle": "Projenizi haftalar değil günler içinde gönderin",
  "cta_text": "Ücretsiz başlayın"
}

Translation Workflow

The complete i18n pipeline follows four steps: extract, copy locale, translate, generate.

Step 1. Extract content (Phase 1 of normalize)

First, extract hardcoded strings into Contentrain content. See the Normalize Flow guide for the full process.

After extraction, you have content in the default locale (e.g., en.json).

Step 2. Copy locale

Create target locale files by copying the source:

"Copy all English content to Turkish"

The agent calls:

contentrain_bulk({
  operation: "copy_locale",
  model: "ui-labels",
  source_locale: "en",
  target_locale: "tr"
})

This creates tr.json with the same structure as en.json, values still in English. Repeat for each model that needs translation.

TIP

Copy locale preserves all keys and structure. The agent translates values in the next step without risk of missing keys.

Step 3. Translate

Ask the agent to translate the copied content:

"Translate all Turkish content. Keep the tone professional and concise. Preserve all placeholders like {name} and {count}."

The agent reads each tr.json file, translates every value, and saves using contentrain_content_save.

For dictionaries:

json
{
  "auth.login.button": "Giriş yap",
  "auth.login.error": "Geçersiz e-posta veya şifre",
  "auth.register.button": "Hesap oluştur",
  "nav.home": "Ana Sayfa",
  "nav.blog": "Blog",
  "nav.pricing": "Fiyatlandırma"
}

WARNING

The agent must preserve all placeholder tokens like {name}, {count}, {price} during translation. These are runtime-replaced values.

Step 4. Regenerate the SDK client

bash
npx contentrain generate

The application now serves localized content from the new locale files.

Parameterized Templates

Contentrain supports {placeholder} syntax for dynamic values in strings:

json
{
  "welcome.greeting": "Welcome back, {name}!",
  "cart.item_count": "You have {count} items in your cart",
  "pricing.monthly": "{price}/month"
}

Placeholders are preserved across all locales:

json
{
  "welcome.greeting": "Tekrar hoş geldin, {name}!",
  "cart.item_count": "Sepetinizde {count} ürün var",
  "pricing.monthly": "{price}/ay"
}

Rendering parameterized strings

The SDK returns raw strings with placeholders. Your application replaces them at runtime:

vue
<script setup lang="ts">
import { dictionary } from '#contentrain'

const labels = dictionary('ui-labels').locale('en').get()

function t(key: string, params: Record<string, string> = {}) {
  let value = labels[key] ?? key
  for (const [k, v] of Object.entries(params)) {
    value = value.replace(`{${k}}`, v)
  }
  return value
}
</script>

<template>
  <p>{{ t('welcome.greeting', { name: user.name }) }}</p>
</template>
tsx
import { dictionary } from '#contentrain'

const labels = dictionary('ui-labels').locale('en').get()

function t(key: string, params: Record<string, string> = {}) {
  let value = labels[key] ?? key
  for (const [k, v] of Object.entries(params)) {
    value = value.replace(`{${k}}`, v)
  }
  return value
}

export function Welcome({ name }: { name: string }) {
  return <p>{t('welcome.greeting', { name })}</p>
}
astro
---
import { dictionary } from '#contentrain'

const labels = dictionary('ui-labels').locale(Astro.currentLocale ?? 'en').get()

function t(key: string, params: Record<string, string> = {}) {
  let value = labels[key] ?? key
  for (const [k, v] of Object.entries(params)) {
    value = value.replace(`{${k}}`, v)
  }
  return value
}
---

<p>{t('welcome.greeting', { name: user.name })}</p>

TIP

If you are already using a framework i18n library (vue-i18n, next-intl, paraglide), use the library's interpolation instead. Contentrain dictionaries can serve as the message source for these libraries.

Agent Prompt Examples

Here are practical prompts for each stage of the i18n workflow:

Setting up locales

"Add Turkish and German as supported locales in my Contentrain config"

Creating dictionaries

"Create a dictionary model for UI labels. Extract all button texts, form labels, and navigation items from my components."

Translating content

"Translate all content in the ui-labels dictionary to Turkish. Use formal tone. Preserve all {placeholder} tokens."

Adding a new locale later

"Add French as a new locale. Copy all existing content from English and translate it."

Reviewing translations

"Check all Turkish translations for missing keys or untranslated values"

The agent calls contentrain_validate which checks for i18n completeness — missing keys, mismatched placeholders, and untranslated values.

SDK Locale Selection

Every SDK query method accepts a .locale() call:

ts
import { query, singleton, dictionary, document } from '#contentrain'

// Collections
const posts = query('blog-post').locale('tr').all()

// Singletons
const hero = singleton('hero').locale('tr').get()

// Dictionaries
const labels = dictionary('ui-labels').locale('tr').get()

// Documents
const article = document('blog-article').locale('tr').bySlug('getting-started')

Dynamic locale from request

ts
// server/api/posts.get.ts
import { query } from '#contentrain'
import { getQuery } from 'h3'

export default defineEventHandler((event) => {
  const locale = getQuery(event).locale?.toString() ?? 'en'
  return query('blog-post').locale(locale).all()
})
tsx
// app/[locale]/blog/page.tsx
import { query } from '#contentrain'

export default async function BlogPage({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params
  const posts = query('blog-post').locale(locale).all()
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
astro
---
import { query } from '#contentrain'

const locale = Astro.currentLocale ?? 'en'
const posts = query('blog-post').locale(locale).all()
---
ts
// src/routes/[locale]/blog/+page.server.ts
import { query } from '#contentrain'
import type { PageServerLoad } from './$types'

export const load: PageServerLoad = ({ params }) => {
  const posts = query('blog-post').locale(params.locale).all()
  return { posts }
}

Content Update Workflow

When dictionary content is added or updated:

  1. Edit content via MCP (contentrain_content_save)
  2. Re-run npx contentrain generate to update the static data modules
  3. Rebuild the application

For development, use watch mode:

bash
npx contentrain generate --watch

Changes to .contentrain/ content trigger automatic client regeneration, which Vite/webpack hot-reloads into the running dev server.

Team Translation Workflow

For teams with dedicated translators or multilingual reviewers, Contentrain Studio provides a visual interface for reviewing translations side-by-side, with approval workflows and version tracking.

Released under the MIT License.