Contentful

Contentful Localization for Multi-Language Sites

A practical guide to Contentful localization covering field-level translations, locale fallbacks, multi-language routing, SEO hreflang tags, and Express.js integration.

Contentful Localization for Multi-Language Sites

Overview

Most CMS platforms bolt localization on as an afterthought -- separate content trees for each language, duplicated entries, or clunky translation plugins that break every time you update the core. Contentful takes a fundamentally different approach: localization is built into the content model at the field level. Every field on every content type can be independently localized, with fallback chains that let you ship partially translated content without broken pages.

This article walks through the full implementation: setting up locales in Contentful, fetching localized content through the Delivery API, building locale-aware routing in Express.js, handling missing translations gracefully, and getting SEO right with hreflang tags. By the end, you will have a working multi-language site serving English, Spanish, and German content from a single Contentful space.

Prerequisites

  • Node.js 18+ installed
  • A Contentful account with a space configured
  • Working knowledge of Express.js routing and middleware
  • Basic understanding of HTTP content negotiation headers

How Contentful Localization Works

Contentful uses field-level localization. Instead of creating separate entries for each language, you create one entry and translate individual fields. A blog post entry might have its title and body fields localized into three languages while sharing the same slug, publishDate, and author reference across all locales.

This matters because it keeps your content graph intact. References between entries work regardless of locale. A blog post referencing an author entry does not need separate references per language -- the relationship is locale-independent, and the author's name resolves to the correct translation when you request a specific locale.

Setting Up Locales

In the Contentful web app, go to Settings > Locales. Every space starts with one default locale (usually en-US). Add additional locales for each language you need to support.

For a typical multi-language site, configure three locales:

Locale Code Name Fallback Required
en-US English (US) None Yes (default)
es Spanish en-US No
de German en-US No

Two critical settings per locale:

  1. Fallback locale -- When a field has no translation for the requested locale, Contentful returns the fallback locale's value instead. Set es and de to fall back to en-US. This prevents empty fields from breaking your templates.

  2. Required in publishing -- Only your default locale should be required. If you mark es as required, editors cannot publish content until every field has a Spanish translation. That kills your workflow. Let editors publish with partial translations and let the fallback chain handle the gaps.

Enabling Localization Per Field

Not every field needs translation. In your content type definition, enable localization only on fields that actually contain translatable text:

  • Localize: title, body, synopsis, metaDescription, buttonLabel
  • Do not localize: slug, publishDate, author (reference), category, sortOrder

This is a deliberate choice. Slugs should stay consistent across locales so your URL structure remains predictable. The locale goes in the URL path prefix, not embedded in the slug itself.

Fetching Localized Content

The Contentful Delivery API accepts a locale parameter on every request. You have two strategies for fetching localized content.

Single Locale Fetch

Request content in one specific locale. Contentful applies the fallback chain automatically and returns a flat field structure:

var contentful = require('contentful');

var client = contentful.createClient({
  space: process.env.CONTENTFUL_SPACE_ID,
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN
});

function getArticle(slug, locale) {
  return client.getEntries({
    content_type: 'blogPost',
    'fields.slug': slug,
    locale: locale,
    include: 2
  }).then(function(response) {
    if (response.items.length === 0) {
      return null;
    }
    return response.items[0];
  });
}

// Usage
getArticle('api-gateway-patterns', 'es').then(function(article) {
  console.log(article.fields.title); // Spanish title, or English fallback
});

The response fields are straightforward -- article.fields.title returns the localized value directly.

All Locales Fetch

Use locale=* to get every locale's value in a single request. The response structure changes -- each field becomes an object keyed by locale code:

function getArticleAllLocales(slug) {
  return client.getEntries({
    content_type: 'blogPost',
    'fields.slug[en-US]': slug,
    locale: '*',
    include: 2
  }).then(function(response) {
    if (response.items.length === 0) {
      return null;
    }
    return response.items[0];
  });
}

// Response structure with locale=*
// article.fields.title = {
//   'en-US': 'API Gateway Patterns',
//   'es': 'Patrones de API Gateway',
//   'de': 'API-Gateway-Muster'
// }

Notice the query filter changes when using locale=*. You must specify the locale on the field filter: fields.slug[en-US] instead of fields.slug. This catches people off guard.

Use the all-locales fetch when you need to build language switcher links or check which translations exist. For rendering pages, single-locale fetch is simpler and produces smaller payloads.

Building a Multi-Language Express.js Application

Here is the complete application structure. I will break down each piece after showing the full picture.

Project Structure

project/
  app.js
  routes/
    pages.js
  views/
    layout.pug
    article.pug
    article_list.pug
  middleware/
    locale.js
  utils/
    contentful.js
    hreflang.js
  package.json

Locale Middleware

The locale middleware detects the user's preferred language and makes it available throughout the request lifecycle:

// middleware/locale.js
var SUPPORTED_LOCALES = ['en-US', 'es', 'de'];
var DEFAULT_LOCALE = 'en-US';

var LOCALE_PREFIXES = {
  'en': 'en-US',
  'es': 'es',
  'de': 'de'
};

function localeMiddleware(req, res, next) {
  var locale = detectLocaleFromPath(req.path);

  if (!locale) {
    locale = detectLocaleFromCookie(req);
  }

  if (!locale) {
    locale = detectLocaleFromHeader(req);
  }

  if (!locale) {
    locale = DEFAULT_LOCALE;
  }

  req.locale = locale;
  res.locals.locale = locale;
  res.locals.localePrefix = getPrefix(locale);
  res.locals.supportedLocales = SUPPORTED_LOCALES;

  next();
}

function detectLocaleFromPath(path) {
  var match = path.match(/^\/([a-z]{2})(\/|$)/);
  if (match && LOCALE_PREFIXES[match[1]]) {
    return LOCALE_PREFIXES[match[1]];
  }
  return null;
}

function detectLocaleFromCookie(req) {
  var cookieLocale = req.cookies && req.cookies.preferred_locale;
  if (cookieLocale && SUPPORTED_LOCALES.indexOf(cookieLocale) !== -1) {
    return cookieLocale;
  }
  return null;
}

function detectLocaleFromHeader(req) {
  var acceptLanguage = req.headers['accept-language'];
  if (!acceptLanguage) {
    return null;
  }

  var languages = acceptLanguage.split(',').map(function(lang) {
    var parts = lang.trim().split(';q=');
    return {
      code: parts[0].substring(0, 2).toLowerCase(),
      quality: parts[1] ? parseFloat(parts[1]) : 1.0
    };
  }).sort(function(a, b) {
    return b.quality - a.quality;
  });

  for (var i = 0; i < languages.length; i++) {
    if (LOCALE_PREFIXES[languages[i].code]) {
      return LOCALE_PREFIXES[languages[i].code];
    }
  }

  return null;
}

function getPrefix(locale) {
  var prefixes = { 'en-US': 'en', 'es': 'es', 'de': 'de' };
  return prefixes[locale] || 'en';
}

module.exports = localeMiddleware;
module.exports.SUPPORTED_LOCALES = SUPPORTED_LOCALES;
module.exports.getPrefix = getPrefix;

The detection priority is intentional: URL path wins over cookie, cookie wins over browser header. When a user clicks a language switcher link, the URL path takes precedence immediately. The cookie persists their choice across navigation. The Accept-Language header serves as the initial best guess for first-time visitors.

Contentful Client Wrapper

// utils/contentful.js
var contentful = require('contentful');

var client = contentful.createClient({
  space: process.env.CONTENTFUL_SPACE_ID,
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN
});

function getArticles(locale, skip, limit) {
  return client.getEntries({
    content_type: 'blogPost',
    locale: locale,
    order: '-fields.publishDate',
    skip: skip || 0,
    limit: limit || 20,
    include: 1
  }).then(function(response) {
    return {
      items: response.items,
      total: response.total,
      skip: response.skip,
      limit: response.limit
    };
  });
}

function getArticleBySlug(slug, locale) {
  return client.getEntries({
    content_type: 'blogPost',
    'fields.slug': slug,
    locale: locale,
    include: 2
  }).then(function(response) {
    if (response.items.length === 0) {
      return null;
    }
    return response.items[0];
  });
}

function getArticleTranslations(slug) {
  return client.getEntries({
    content_type: 'blogPost',
    'fields.slug[en-US]': slug,
    locale: '*',
    select: 'fields.title,fields.slug',
    include: 0
  }).then(function(response) {
    if (response.items.length === 0) {
      return {};
    }
    var fields = response.items[0].fields;
    var translations = {};
    var locales = Object.keys(fields.title || {});
    locales.forEach(function(loc) {
      translations[loc] = {
        title: fields.title[loc],
        exists: true
      };
    });
    return translations;
  });
}

module.exports = {
  getArticles: getArticles,
  getArticleBySlug: getArticleBySlug,
  getArticleTranslations: getArticleTranslations
};

The getArticleTranslations function uses locale=* with a narrow select to fetch only the fields needed for language switcher links. This keeps the payload small -- you do not want to pull full article bodies in three languages just to render a dropdown.

Locale-Aware Routing

// routes/pages.js
var express = require('express');
var router = express.Router();
var cms = require('../utils/contentful');
var localeMiddleware = require('../middleware/locale');
var buildHreflang = require('../utils/hreflang');

// Redirect root to default locale
router.get('/', function(req, res) {
  var prefix = localeMiddleware.getPrefix(req.locale);
  res.redirect(302, '/' + prefix + '/');
});

// Localized article list
router.get('/:lang/', function(req, res, next) {
  var locale = resolveLocale(req.params.lang);
  if (!locale) {
    return next();
  }

  var page = parseInt(req.query.page) || 1;
  var perPage = 20;
  var skip = (page - 1) * perPage;

  cms.getArticles(locale, skip, perPage).then(function(result) {
    res.render('article_list', {
      articles: result.items,
      total: result.total,
      page: page,
      perPage: perPage,
      locale: locale,
      lang: req.params.lang,
      hreflangTags: buildHreflang.forList(req)
    });
  }).catch(next);
});

// Localized article detail
router.get('/:lang/articles/:slug', function(req, res, next) {
  var locale = resolveLocale(req.params.lang);
  if (!locale) {
    return next();
  }

  var slug = req.params.slug;

  Promise.all([
    cms.getArticleBySlug(slug, locale),
    cms.getArticleTranslations(slug)
  ]).then(function(results) {
    var article = results[0];
    var translations = results[1];

    if (!article) {
      return next();
    }

    res.render('article', {
      article: article,
      translations: translations,
      locale: locale,
      lang: req.params.lang,
      hreflangTags: buildHreflang.forArticle(req, slug, translations)
    });
  }).catch(next);
});

// Set locale preference
router.post('/set-locale', function(req, res) {
  var locale = req.body.locale;
  var supportedLocales = localeMiddleware.SUPPORTED_LOCALES;

  if (supportedLocales.indexOf(locale) === -1) {
    return res.status(400).json({ error: 'Unsupported locale' });
  }

  res.cookie('preferred_locale', locale, {
    maxAge: 365 * 24 * 60 * 60 * 1000,
    httpOnly: true,
    sameSite: 'lax'
  });

  var redirect = req.body.redirect || '/';
  res.redirect(302, redirect);
});

function resolveLocale(lang) {
  var map = { 'en': 'en-US', 'es': 'es', 'de': 'de' };
  return map[lang] || null;
}

module.exports = router;

URL Strategy

There are three common URL patterns for localized sites:

Strategy Example Pros Cons
Path prefix /es/articles/my-post Simple, single domain Requires routing logic
Subdomain es.example.com/articles/my-post Clean separation DNS and SSL per subdomain
Separate domain example.es/articles/my-post Strong geo-targeting Expensive, complex

Path prefixes win for most projects. They work with a single SSL certificate, a single deployment, and simple Express.js routing. The code above uses this approach. Subdomains add operational complexity that is rarely justified unless you need completely separate infrastructure per region.

Hreflang Tag Generation

Search engines use hreflang tags to understand which page version to show users in each language. Getting this wrong causes duplicate content penalties or the wrong language appearing in search results.

// utils/hreflang.js
var SUPPORTED_LOCALES = require('../middleware/locale').SUPPORTED_LOCALES;
var getPrefix = require('../middleware/locale').getPrefix;

var BASE_URL = process.env.BASE_URL || 'https://example.com';

function forArticle(req, slug, translations) {
  var tags = [];

  SUPPORTED_LOCALES.forEach(function(locale) {
    if (translations[locale] && translations[locale].exists) {
      var prefix = getPrefix(locale);
      tags.push({
        locale: locale,
        hreflang: prefix,
        href: BASE_URL + '/' + prefix + '/articles/' + slug
      });
    }
  });

  // x-default points to the English version
  tags.push({
    locale: 'x-default',
    hreflang: 'x-default',
    href: BASE_URL + '/en/articles/' + slug
  });

  return tags;
}

function forList(req) {
  var tags = [];

  SUPPORTED_LOCALES.forEach(function(locale) {
    var prefix = getPrefix(locale);
    tags.push({
      locale: locale,
      hreflang: prefix,
      href: BASE_URL + '/' + prefix + '/'
    });
  });

  tags.push({
    locale: 'x-default',
    hreflang: 'x-default',
    href: BASE_URL + '/en/'
  });

  return tags;
}

module.exports = {
  forArticle: forArticle,
  forList: forList
};

In your Pug layout, render the tags in the <head>:

//- views/layout.pug
head
  each tag in (hreflangTags || [])
    link(rel="alternate" hreflang=tag.hreflang href=tag.href)

Every page with translations must include hreflang tags pointing to all alternate versions, including itself. The x-default tag tells search engines which version to show when none of the specified locales match the user.

Language Switcher

The language switcher links to the same content in a different locale. This is where the translations data from the locale=* fetch becomes useful:

//- Language switcher partial
nav.language-switcher
  each loc in supportedLocales
    - var prefix = loc === 'en-US' ? 'en' : loc
    - var label = { 'en-US': 'English', 'es': 'Español', 'de': 'Deutsch' }
    if locale === loc
      span.active= label[loc]
    else
      a(href='/' + prefix + req.path.replace(/^\/[a-z]{2}/, ''))= label[loc]

Localizing Assets and Media

Contentful supports localized assets. You can upload different images per locale -- for example, a banner image with text baked into the graphic needs a separate version for each language.

Enable localization on asset fields the same way you do content fields. When fetching with a specific locale, Contentful returns the localized asset URL. If no localized version exists, it falls back through the chain.

For most media -- photos, icons, diagrams without text -- do not localize the asset. Localizing assets that do not need it wastes storage and creates editorial overhead.

Rich Text Localization

If you use Contentful's Rich Text field type, localization works at the field level just like Short Text or Long Text. The entire Rich Text document is stored per locale. There is no partial localization within a Rich Text document -- you translate the whole thing or it falls back entirely.

Watch out for embedded entries and assets inside Rich Text. If your Rich Text references an entry that itself has localized fields, you need to resolve those references with the correct locale. The Delivery API handles this automatically when you use the locale parameter with include set appropriately, but if you are doing manual resolution, you must pass the locale through.

Date and Number Formatting

Content localization is only half the story. Dates, numbers, and currencies must also follow locale conventions:

var LOCALE_MAP = {
  'en-US': 'en-US',
  'es': 'es-ES',
  'de': 'de-DE'
};

function formatDate(date, contentfulLocale) {
  var jsLocale = LOCALE_MAP[contentfulLocale] || 'en-US';
  var d = new Date(date);
  return d.toLocaleDateString(jsLocale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  });
}

function formatNumber(num, contentfulLocale) {
  var jsLocale = LOCALE_MAP[contentfulLocale] || 'en-US';
  return num.toLocaleString(jsLocale);
}

// formatDate('2026-02-13', 'de') => "13. Februar 2026"
// formatDate('2026-02-13', 'en-US') => "February 13, 2026"
// formatNumber(1234567.89, 'de') => "1.234.567,89"

Expose these as Pug helpers through res.locals or app.locals so your templates can format inline:

// In app.js or middleware
app.use(function(req, res, next) {
  res.locals.formatDate = function(date) {
    return formatDate(date, req.locale);
  };
  res.locals.formatNumber = function(num) {
    return formatNumber(num, req.locale);
  };
  next();
});

Application Entry Point

// app.js
var express = require('express');
var cookieParser = require('cookie-parser');
var localeMiddleware = require('./middleware/locale');
var pages = require('./routes/pages');

var app = express();

app.set('view engine', 'pug');
app.set('views', __dirname + '/views');

app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(cookieParser());
app.use(express.static('static'));

// Locale detection on every request
app.use(localeMiddleware);

// Routes
app.use('/', pages);

// 404 handler
app.use(function(req, res) {
  res.status(404).render('404', { locale: req.locale });
});

// Error handler
app.use(function(err, req, res, next) {
  console.error(err.stack);
  res.status(500).render('500', { locale: req.locale });
});

var port = process.env.PORT || 3000;
app.listen(port, function() {
  console.log('Server running on port ' + port);
});

Content Workflow for Translators

Setting up the technical infrastructure is the easy part. The harder problem is managing translation workflows at scale. Here is what works:

Separate editor roles. Create Contentful roles for translators with permissions scoped to specific locales. A Spanish translator should not accidentally edit the English source content.

Translate from published content. Translators should work from the published English version, not drafts. This prevents wasted effort translating content that might change before publication.

Use the Contentful UI's locale comparison view. The web app lets editors view fields side by side in two locales. This is far more productive than switching back and forth.

Consider translation management tools. For large-scale operations, integrate with services like Phrase (formerly Memsource), Crowdin, or Lokalise. These tools sync with Contentful through webhooks or the Management API, provide translation memory, glossaries, and progress tracking. The Contentful Marketplace has integrations for several platforms.

For smaller teams, the built-in Contentful editor with locale comparison is usually sufficient. Do not over-engineer the workflow until you actually have a translation bottleneck.

Common Issues and Troubleshooting

1. Field Filters Fail with locale=*

When querying with locale=*, field-based filters must include the locale in the field path. 'fields.slug': 'my-post' silently returns no results. Use 'fields.slug[en-US]': 'my-post' instead. This is documented but easy to miss, and the API does not return an error -- it just returns an empty result set.

2. Fallback Returns Unexpected Locale

Your fallback chain might produce surprises. If German falls back to English, but you expected it to fall back to nothing (so you could show a "translation unavailable" message), you will never see the gap. To detect missing translations, fetch with locale=* and check whether the locale key exists in the field object. If the key is absent, there is no translation -- the Delivery API just applied the fallback silently.

function hasTranslation(entry, fieldName, locale) {
  // Only works with locale=* response
  var field = entry.fields[fieldName];
  return field && field.hasOwnProperty(locale);
}

3. Linked Entries Not Resolving in Correct Locale

When using include to resolve linked entries, the locale parameter applies to the linked entries too. But if a linked entry does not have localization enabled on a field, the field returns the same value regardless of locale. If you are getting English values for linked entries when requesting Spanish, check whether those fields have localization enabled in the content type definition.

4. Stale Locale Cookie After Removing a Locale

If you remove a supported locale from your application but users still have cookies with the old locale value, the middleware will reject the cookie value and fall back to Accept-Language detection. Make sure your detectLocaleFromCookie function validates against the current SUPPORTED_LOCALES array, as shown in the middleware above. Without that check, you will get undefined behavior or errors.

5. Hreflang Tags Missing Self-Reference

A common SEO mistake is generating hreflang tags that point to other language versions but omit the current page. Google requires every page in a hreflang set to include a tag pointing to itself. The forArticle function above handles this correctly by iterating all locales unconditionally.

Best Practices

  1. Only localize fields that need translation. Slugs, dates, booleans, references, and numeric values rarely need per-locale variants. Each localized field multiplies editorial effort and storage.

  2. Set fallback chains deliberately. Every non-default locale should fall back to your default locale. Without a fallback, missing translations return undefined, which will crash your templates or produce blank pages.

  3. Use select to limit response size. When you only need titles for a listing page, use select: 'fields.title,fields.slug,fields.publishDate'. With three locales and locale=*, payloads grow fast.

  4. Cache Contentful responses per locale. Your cache key must include the locale. A common bug is caching the English response and serving it for Spanish requests. Use keys like articles:list:es:page:1.

  5. Validate locale parameters at the routing layer. Never pass user-supplied locale strings directly to the Contentful API. Validate against your whitelist of supported locales first. An invalid locale parameter returns a 400 error from Contentful, which turns into a 500 on your site if unhandled.

  6. Include x-default hreflang on every localized page. This tells search engines what to show users whose language does not match any of your supported locales. Point it to your default language version.

  7. Do not redirect based on Accept-Language alone. Detecting the browser language and immediately redirecting is hostile to users. Someone in Germany using an English browser wants English content. Use Accept-Language as a suggestion for first-time visitors, then respect their explicit choice via URL path or cookie.

  8. Test with partial translations. Your site must render correctly when only some fields are translated. Build templates that handle fallback content gracefully -- do not assume every field will have a value in the requested locale.

References

Powered by Contentful