Handling Multi-Locale Slug Transliteration

Transliterate non-Latin titles into stable, collision-free slugs by running a locale-aware romanization step before the shared normalization pipeline your Latin content already uses.

When to Use This Approach

Add locale-aware transliteration to your slug pipeline when any of these conditions apply:

  • Your CMS publishes content in non-Latin scripts (Arabic, Cyrillic, Greek, Japanese, Korean, or Chinese) and the default slug normalization pipeline produces empty or truncated paths.
  • You run a mixed-script catalog where a single locale mixes native script and Latin loanwords, so a naive strip-diacritics pass silently deletes half of every title.
  • You operate multiple language variants under international routing and need each locale to follow its own romanization rules rather than one global scheme.

Locale-aware transliteration pipeline A native-script CMS title is dispatched by its locale code to the matching transliteration map, then passed through the shared normalizeSlug pass to yield a stable ASCII slug. Native Title Locale Dispatch Transliterate to ASCII normalizeSlug shared pass Stable Slug "Привет мир" locale=ru "Privet mir" "privet-mir" /ru/privet-mir

Implementation Steps

Step 1: Add a Per-Locale Transliteration Step

Insert a transliteration pass in front of the shared normalizer. The romanization must run first because the base normalization utility assumes its input is already Latin text — NFD decomposition has nothing to decompose in Cyrillic or Arabic.

// lib/localized-slug.js — locale-aware wrapper around the shared normalizer
const { transliterate } = require('transliteration');
const { normalizeSlug } = require('./slug');
const { LOCALE_SCHEMES } = require('./locale-schemes');

function localizedSlug(rawTitle, locale) {
  const scheme = LOCALE_SCHEMES[locale] || LOCALE_SCHEMES.default;
  // 1. Romanize native script to ASCII using the locale's rules
  const romanized = transliterate(rawTitle, { replace: scheme.replace });
  // 2. Hand off to the SAME normalizer the Latin content already uses
  return normalizeSlug(romanized);
}

module.exports = { localizedSlug };

Validation:

node -e "
const { localizedSlug } = require('./lib/localized-slug');
console.log('ru', localizedSlug('Привет мир', 'ru'));
console.log('el', localizedSlug('Καλημέρα κόσμε', 'el'));
"
# Expected: ru privet-mir   /   el kalimera-kosme  (non-empty ASCII slugs)

Step 2: Configure Locale Maps

Register one transliteration scheme per locale code. Different languages romanize the same character differently — German transliterates ü to ue, while a generic pass yields u — so the map is where you encode those locale-specific decisions.

// lib/locale-schemes.js
const LOCALE_SCHEMES = {
  default: { replace: [] },
  de: { replace: [['ä', 'ae'], ['ö', 'oe'], ['ü', 'ue'], ['ß', 'ss']] },
  ru: { replace: [['ё', 'yo'], ['й', 'y']] },
  ja: { replace: [] }, // handled by the transliteration engine's kana tables
  ar: { replace: [['ة', 'a']] },
};

module.exports = { LOCALE_SCHEMES };

Validation:

node -e "
const { localizedSlug } = require('./lib/localized-slug');
console.log(localizedSlug('Über Größe', 'de'));  // expect: ueber-groesse
console.log(localizedSlug('Über Größe', 'default')); // expect: uber-grosse
"

The two outputs must differ, proving the locale scheme is applied rather than the default.


Step 3: Enforce a Unique Constraint Scoped by Locale

Transliteration compresses a large character space into ASCII, so distinct native titles can romanize to the same slug. Catch this at write time with a database unique constraint scoped by locale, and append a deterministic suffix on collision.

// api/assign-slug.js — runs inside the CMS pre-publish hook
const { localizedSlug } = require('../lib/localized-slug');

async function assignSlug(db, { title, locale, entryId }) {
  const base = localizedSlug(title, locale);
  let candidate = base;
  let n = 1;
  // Uniqueness is scoped to (locale, slug), never global
  while (await db.slugExists({ locale, slug: candidate, notEntryId: entryId })) {
    n += 1;
    candidate = `${base}-${n}`;
  }
  return candidate;
}

module.exports = { assignSlug };

Validation:

# Insert two entries whose titles romanize identically, then read them back
curl -s "https://staging.yourdomain.com/api/debug/slugs?locale=ru" | jq '.[].slug'
# Expected: "privet-mir" and "privet-mir-2" — never a duplicate pair

Step 4: Freeze Historical Slugs and Redirect

Once a slug is published it becomes a crawlable URL, so regenerating it later strands inbound links. Store the first slug immutably and route any regenerated value to it with a permanent redirect, keeping the redirect map short by pointing straight at the frozen target.

// scripts/freeze-slugs.js — lock the first published slug per entry+locale
const { localizedSlug } = require('../lib/localized-slug');

function reconcile(entry) {
  const regenerated = localizedSlug(entry.title, entry.locale);
  if (entry.publishedSlug && regenerated !== entry.publishedSlug) {
    // Keep the live URL; redirect the newly generated form to it
    return {
      keep: entry.publishedSlug,
      redirect: { source: `/${entry.locale}/${regenerated}`, destination: `/${entry.locale}/${entry.publishedSlug}`, permanent: true },
    };
  }
  return { keep: entry.publishedSlug || regenerated, redirect: null };
}

module.exports = { reconcile };

Validation:

node scripts/freeze-slugs.js --dry-run | jq '.redirects | length'
# A rules refactor should produce 0 redirects for unchanged titles.

Step 5: Validate Every Locale With Fixtures

Run a fixture set covering each active locale before promoting the pipeline. Determinism is the property under test: the same title and locale must always yield the same slug across builds.

# tests run the fixture titles through localizedSlug for every locale
npx jest lib/localized-slug.test.js

# Spot-check the rendered path resolves and self-canonicalizes
curl -s https://staging.yourdomain.com/ru/privet-mir \
  | grep -oP '(?<=canonical" href=")[^"]+'
# Must return the /ru/privet-mir absolute URL, matching the served path

SEO Impact Summary

Signal What improves What breaks if misconfigured
Indexation Every locale yields a non-empty, crawlable ASCII path NFD-only pipelines emit empty slugs for non-Latin titles, producing unreachable routes
URL stability Frozen historical slugs keep live URLs constant across rule changes Regenerating slugs on rule edits silently 404s previously indexed pages
Locale targeting Locale-scoped uniqueness lets the same romanized slug serve multiple languages Global uniqueness forces ugly numeric suffixes onto legitimate cross-locale twins
Duplicate prevention Deterministic suffixes disambiguate genuine collisions Naive overwrite drops the earlier entry’s route on the second publish

Measurable signals to watch:

  • GSC Coverage: “Discovered - currently not indexed” for non-Latin locales should fall as empty-slug routes disappear.
  • CDN 404 rate per locale prefix should stay flat after any transliteration rule change, confirming frozen slugs held.
  • Indexed URL count per locale should track published-entry count with no gap attributable to collisions.

Edge Cases and Gotchas

Empty slugs from pure-symbol titles A title composed entirely of punctuation or emoji romanizes to an empty string, which then normalizes to "". Guard against this by falling back to the entry’s numeric ID (entry-4821) whenever localizedSlug returns an empty value, so the route is always addressable even if not pretty.

Preview environments and unfrozen slugs CMS preview URLs often bypass the pre-publish hook, so an editor can preview a title under its freshly regenerated slug rather than the frozen one. Ensure your sitemap generation reads only the frozen publishedSlug field, and block preview hosts in robots rules so the unfrozen variant never gets crawled.

Mixed-script titles within one field A Japanese title containing Latin loanwords (“iPhone レビュー”) must pass through transliteration without mangling the ASCII segments. The transliteration engine leaves existing ASCII intact, but verify with a fixture — a misconfigured replace map can lowercase or split product names in ways that hurt brand-term matching.

Locale detection drift If the locale code passed to localizedSlug is wrong — for example a default falling back to en for Russian content — the wrong scheme applies and the slug shifts. Treat the locale as a required argument, throw on undefined, and reconcile the resolved locale against the same signals your international routing layer uses so the two never disagree.

Frequently Asked Questions

Does NFD normalization handle non-Latin scripts? No. NFD decomposition only splits accented Latin characters into a base letter plus a combining mark, and the normalizer then strips the mark. Cyrillic, Arabic, Greek, and CJK characters have no Latin base, so the strip pass deletes them and you are left with an empty slug. That is exactly why the transliteration step must run before the shared normalization utility.

Should slugs be transliterated or localized? Transliterate to ASCII when your routing, analytics, and CDN tooling assume ASCII paths — it is the safest default and keeps URLs copy-pasteable everywhere. Keep native-script slugs only if your entire stack handles percent-encoded Unicode paths without breaking. Whichever you choose, apply one deterministic policy per locale so a title always maps to the same slug.

How do I avoid slug collisions across locales? Scope uniqueness by locale rather than globally, so /ru/privet-mir and a romanized twin in another language can coexist. Inside a single locale, enforce a database unique constraint on the normalized slug and append a deterministic numeric suffix when a genuine collision occurs, exactly as the assignment hook above does.

Can I change the transliteration rules after launch? You can, but never let a rule change rewrite live URLs. Freeze each entry’s first published slug and route any newly generated form to it with a permanent redirect. New content picks up the updated rules while existing indexed pages keep their addresses, avoiding a wave of 404s.


Part of: Slug Normalization Strategies

Related