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.
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
- Implementing SEO-Friendly Slug Normalization — the shared normalization pipeline this transliteration step feeds into
- hreflang & International Routing in Headless — align locale detection and reciprocal locale signals with your slug rules
- Dynamic Routing & Indexation Workflows — the parent reference covering routing, indexation, and URL architecture for headless stacks