Eternalsix

URL slugs that don't break: Unicode, collisions, and why you never change one

A slug looks like a formatting problem and behaves like a database key. Here's what actually goes wrong — accented characters, duplicate titles, route collisions — and how to decide before you ship.

A slug is the human-readable part of a URL: the url-slugs-that-dont-break in this page's address. You generate it from a title, and the job looks trivial — lowercase it, replace spaces with hyphens, done.

Then someone publishes a post titled "Café or Cafe?", two articles get the same title in different months, and a user creates a page called "new". Each of those breaks something different. The underlying reason is that a slug is not formatting: it is an identifier that happens to be readable, and identifiers have rules.

Step one is Unicode, not string replacement

"Café" can be two different byte sequences. In NFC it's caf plus U+00E9 (é as one code point). In NFD it's caf, e, then U+0301 (a combining acute accent). They render identically. They are not equal as strings, and they do not hash the same.

If your slug function strips "non-ASCII characters", NFC "café" becomes caf and NFD "café" becomes cafe — the same visible title produces two different slugs depending on which keyboard or operating system typed it. That is a real bug that looks like magic.

So the first step is always: normalize, then decide. Normalize to NFD, strip the combining marks, and you get a predictable transliteration:

"Café"  → NFD → "cafe" + U+0301 → strip marks → "cafe"
"Müller" → "muller"
"naïve"  → "naive"

This is what most slug libraries do, and it's a reasonable default for Latin scripts. Know its limits: German convention would render "Müller" as "mueller", and for Korean, Japanese, Chinese, Arabic, or Cyrillic there are no combining marks to strip — stripping non-ASCII deletes the entire title and leaves you with an empty slug.

You have three options for non-Latin titles, and they're all trade-offs

Keep the original characters. Modern browsers accept them and display them decoded in the address bar; on the wire they're percent-encoded UTF-8. A Korean title stays readable to Korean readers. The cost: the URL is unreadable when copied into a plain-text context (%ED%95%9C%EA%B5%AD%EC%96%B4), and some older tooling mangles it.

Transliterate. "한국어" → "hangugeo". Readable everywhere, but transliteration is lossy and often ugly, and different libraries disagree on the romanization.

Fall back to an ID. If the slug comes out empty or is pure punctuation, use the record's ID. Never ship an empty slug — you get a URL ending in //, which is a different route.

There is no universally right answer. Pick one deliberately and apply it everywhere, because mixing them is what produces a site where some URLs are readable and some are hex.

The one that bites: two titles, one slug

Titles are not unique. "Weekly update" will be used again. If your slug is a primary key or a unique column, the second insert fails; if it isn't, you now have two rows matching one URL and the route returns whichever the database happens to hand back first.

The usual fix is a numeric suffix — weekly-update, weekly-update-2 — assigned at creation time by checking what already exists. Two things to get right:

Slugs collide with routes, too

/posts/new is a slug. It is also, in most frameworks, the route for the "create a post" page. Whichever the router matches first wins, and the loser becomes unreachable. The same applies to edit, admin, api, login, and anything else your app mounts as a literal path segment.

Keep a reserved-word list and reject or suffix those slugs at creation. It costs five minutes and prevents a class of bug that is very confusing to diagnose.

Case, length, and the characters worth excluding

URL paths are case-sensitive. The host part isn't — EXAMPLE.com and example.com are the same server — but /About and /about are different paths by the spec, and many servers treat them that way. Lowercase every slug at creation so you never have to think about it again.

Cap the length. Titles can be a paragraph. Slugs shouldn't be. Truncate at a word boundary around 60–80 characters; the tail of a long slug carries almost no meaning and makes the URL hard to share.

Trim the edges. A leading or trailing hyphen is ugly and easy to produce ("Hello!" → hello-), and consecutive separators should collapse: "a -- b" should be a-b, not a---b.

The rule that matters most: don't change a published slug

Once a URL is public, it's in someone's bookmarks, in a chat log, in a search index, and in links from other sites. Editing the title should not change the slug. If you do change it, the old URL must 301-redirect to the new one, and you have to keep that redirect essentially forever.

This is why it's worth treating the slug as a stored column set once at creation, rather than a function of the current title computed on every render. The computed version silently breaks every link the moment someone fixes a typo in a headline.

Generating one to check

When you're deciding on a convention, the fastest way to see what a rule actually produces is to run titles through it — especially the awkward ones: accented characters, emoji, a title that's all punctuation, one in a non-Latin script. A Slug Generator that runs in your browser lets you try those cases without writing a script first, and without sending unpublished titles to a third-party service.

The takeaway

A slug is an identifier wearing readable clothes. Normalize Unicode before you strip anything, or the same visible title will produce different slugs on different machines. Assign the slug once, at creation, with a unique constraint and a retry rather than a check-then-insert race. Keep a reserved-word list so user content can't shadow your own routes. And once a URL is published, treat it as permanent: change the title freely, change the slug only with a redirect you're prepared to keep.

If you're deciding what may safely appear in a URL at all, the companion question is covered in Base64, URL encoding, and percent-encoding.