Back to blog
A Contentful-style content entry card showing a filled English locale tab and an empty French locale tab with a warning icon.

Why your Contentful content isn't actually multilingual yet

By Robert

Why your Contentful content isn't actually multilingual yet

Contentful handles locales well. You can define as many as you need, set a fallback chain, switch between them in the editor, and build a frontend that serves the right language per route. The infrastructure for multilingual content is genuinely good.

What Contentful does not do is translate your content.

This sounds obvious when stated directly, but it is surprisingly easy to conflate "we have locale fields set up" with "we are multilingual." The two are not the same thing. Locale fields are a container. Multilingual means the container has content in it.

If your Contentful space has French and German locales configured but the fields in those locales are empty, or filled with the English copy as a placeholder, or populated with a rough first pass that nobody has reviewed since the site launched — you are not multilingual. You have the scaffolding for multilingual.

This post is about closing that gap.


What Contentful actually gives you

Contentful's locale system is well designed. Each content entry can have field values per locale. You set a default locale, configure fallbacks, and your delivery API returns the right locale when requested. The content modelling is flexible enough to handle complex multilingual requirements across different content types.

But the system is entirely neutral on how the translated content gets in there. Contentful does not know whether your French locale fields contain professional translations, machine-translated copy, English text pasted in by accident, or nothing at all. It just stores and delivers what you put in.

The translation problem is entirely yours to solve.


How teams usually handle it (and where it breaks down)

Most teams handling Contentful translation fall into one of a few patterns.

The manual export pattern. A developer exports content from Contentful, sends it to a translation agency or freelancer, waits for it to come back, reformats it, and imports it. This works for a one-time launch but becomes untenable as content changes. Every update to the source locale means going through the whole cycle again. In practice, translated content quickly falls behind the source and nobody has time to catch it up.

The in-editor translation pattern. An editor opens each entry, switches to the target locale, and translates field by field either manually or by pasting content into a translation tool. This is accurate but slow. It also doesn't scale — if you have hundreds of entries across a dozen content types, the volume of manual work is significant.

The "it'll do" pattern. The translated content exists but hasn't been reviewed since it was first produced. The source locale has been updated multiple times since then. The French version of your pricing page still references a plan you retired eight months ago. The German homepage still has the old tagline. Nobody has flagged it because nobody checks.

All three patterns share the same root problem: translation is treated as a one-time task rather than an ongoing part of the content workflow.


What multilingual actually requires

Genuine multilingual content in Contentful needs three things working together.

Accurate initial translation. Every field in every target locale needs translated content that is accurate, appropriately localised, and actually reads as if it was written for that market rather than run through a basic translation tool and left unreviewed.

A process for keeping translations current. When source content changes, translated content needs to change too. This is the part most teams underestimate. A content team that publishes several updates a week across a Contentful space with four locales and fifty content types is looking at a significant ongoing translation workload if it's handled manually.

A way to know when translations are out of date. Contentful's locale system does not surface staleness. If you update the English copy of an entry and forget to update the French copy, the French version will silently continue serving the old content. You need either a process or tooling to catch this.


Where PolyLingo fits

PolyLingo's API translates structured content while preserving its structure. For Contentful, the relevant format is JSON — a Contentful entry's field values, extracted and sent to the API, come back translated with the same structure intact.

The basic flow in Node.js:

import PolyLingo from 'polylingo'
import { createClient } from 'contentful-management'
 
const poly = new PolyLingo({ apiKey: process.env.POLYLINGO_API_KEY })
const contentful = createClient({
  accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN,
})
 
const space = await contentful.getSpace(process.env.CONTENTFUL_SPACE_ID)
const environment = await space.getEnvironment('master')
 
// Fetch the entry you want to translate
const entry = await environment.getEntry('your-entry-id')
 
// PolyLingo uses BCP-47 codes without region (fr, de).
// Map these to your Contentful locale IDs if they differ (e.g. fr-FR, de-DE).
const localeMap = { fr: 'fr', de: 'de' } // adjust to match your space's locale IDs
 
// Extract English string fields only — skip references, links, booleans, and numbers
const sourceFields = Object.fromEntries(
  Object.entries(entry.fields)
    .filter(([, value]) => typeof value['en-US'] === 'string')
    .map(([key, value]) => [key, value['en-US']])
)
 
// Translate to French and German in one request
const result = await poly.translate({
  content: JSON.stringify(sourceFields),
  format: 'json',
  targets: Object.keys(localeMap),
})
 
// Write translated values back to the entry using Contentful locale IDs
for (const [polyLocale, translated] of Object.entries(result.translations)) {
  const contentfulLocale = localeMap[polyLocale]
  const parsed = JSON.parse(translated)
  for (const [field, value] of Object.entries(parsed)) {
    if (!entry.fields[field]) entry.fields[field] = {}
    entry.fields[field][contentfulLocale] = value
  }
}
 
await entry.update()
await entry.publish()
 
console.log('Entry translated and published.')

One API call returns both French and German. The entry is updated and published in the same script.

A note on locale IDs: PolyLingo uses BCP-47 codes without a region suffix (fr, de). Contentful spaces often use region-qualified IDs like fr-FR or de-DE. The localeMap object above is where you align them — update it to match whatever locale IDs your Contentful space uses.

The same pattern works in Python:

import os, json, requests
from contentful_management import Client
 
poly_key = os.environ['POLYLINGO_API_KEY']
contentful = Client(os.environ['CONTENTFUL_MANAGEMENT_TOKEN'])
 
space = contentful.spaces().find(os.environ['CONTENTFUL_SPACE_ID'])
environment = space.environments().find('master')
entry = environment.entries().find('your-entry-id')
 
# Extract English field values
source_fields = {
    key: value.get('en-US')
    for key, value in entry.fields().items()
    if value.get('en-US')
}
 
# Translate to French and German
r = requests.post(
    'https://api.usepolylingo.com/v1/translate',
    headers={'Authorization': f"Bearer {poly_key}"},
    json={
        'content': json.dumps(source_fields),
        'format': 'json',
        'targets': ['fr', 'de'],
    },
    timeout=120,
)
r.raise_for_status()
translations = r.json()['translations']
 
# Write back to entry
for locale, translated in translations.items():
    parsed = json.loads(translated)
    for field, value in parsed.items():
        if field in entry.fields():
            entry.fields()[field][locale] = value
 
entry.save()
entry.publish()
print('Entry translated and published.')

Translating at scale

The examples above handle a single entry. For a Contentful space with many entries across multiple content types, the batch endpoint handles up to 100 items per request:

// Fetch multiple entries and translate them in one batch call
const entries = await environment.getEntries({
  content_type: 'blogPost',
  limit: 50,
})
 
// Map PolyLingo locale codes to Contentful locale IDs
const localeMap = { fr: 'fr', de: 'de', es: 'es' } // adjust to match your space
 
const items = entries.items.map(entry => ({
  id: entry.sys.id,
  content: JSON.stringify(
    Object.fromEntries(
      Object.entries(entry.fields)
        .filter(([, value]) => typeof value['en-US'] === 'string')
        .map(([k, v]) => [k, v['en-US']])
    )
  ),
  format: 'json',
}))
 
const batch = await poly.batch({
  items,
  targets: Object.keys(localeMap),
})
 
for (const result of batch.results) {
  const entry = entries.items.find(e => e.sys.id === result.id)
  for (const [polyLocale, translated] of Object.entries(result.translations)) {
    const contentfulLocale = localeMap[polyLocale]
    const parsed = JSON.parse(translated)
    for (const [field, value] of Object.entries(parsed)) {
      if (!entry.fields[field]) entry.fields[field] = {}
      entry.fields[field][contentfulLocale] = value
    }
  }
  await entry.update()
  await entry.publish()
}

Fifty entries, three languages, one batch request.


Keeping translations current

The scripts above can be run on demand or wired into your publishing workflow. A common pattern is to run translation as part of a CI job triggered when source content changes, or on a schedule that catches any entries updated in the last 24 hours.

Contentful also supports webhooks — you can configure a webhook that fires when an entry is published in the default locale, which can trigger a translation job automatically. Wiring PolyLingo into that webhook means every time an editor publishes updated English content, the translated locales are updated without any manual step. This is on the roadmap for a dedicated PolyLingo integration; in the meantime the API gives you everything you need to build that flow yourself.


A note on field types

The approach above works well for short text, long text, and rich text stored as strings. A few Contentful field types need handling separately:

Rich text fields stored in Contentful's document format (not plain strings) need to be serialised to plain text or HTML before sending to PolyLingo, then deserialised back. The @contentful/rich-text-html-renderer package handles the serialisation step, and format: "html" on the PolyLingo side preserves the markup correctly.

Reference fields (links to other entries or assets) should not be translated — they are IDs, not text. Exclude them from the fields you send to the API.

Number, boolean, and date fields are not translatable by nature. Filter these out before building your source object.


Getting started

PolyLingo's free tier includes 50,000 tokens per month. For a Contentful space with a moderate amount of content, that covers an initial translation pass across several entries in multiple locales with room to spare for ongoing updates.

Full API documentation is at usepolylingo.com/docs. SDK packages are available for Node.js, Python, Ruby, PHP, Java, and Go.

npm install polylingo

Get your API key

Why your Contentful content isn't actually multilingual yet | PolyLingo