Every web developer ships slugs. Almost nobody thinks about them until one breaks: a duplicate-content SEO problem, a 404 after someone renames a post, or a bug report with a URL full of %E2%80%99.
This post covers what a good slug looks like, the edge cases that break naive implementations, and a copy-paste slugify() you can actually trust.
What is a slug, precisely?
The slug is the human-readable identifier segment of a URL path:
https://example.com/blog/url-slugs-best-practices
└───────── slug ─────────┘
It usually derives from a title, but it is not the title. It's an identifier with three jobs:
- Readable by humans — in the address bar, in search results, in a Slack message.
- Stable — it's part of a permalink. Links, bookmarks, and search engine indexes depend on it not changing.
- Safe — no encoding surprises across browsers, CDNs, log pipelines, and markdown parsers.
The rules that matter
1. Lowercase, always
/Blog/My-Post and /blog/my-post are different URLs to most servers and to Google. Mixed case invites duplicate-content splits and broken links from people retyping URLs. Normalize to lowercase at creation time and 301-redirect the wrong-case variants.
2. Hyphens, not underscores
Google has been explicit about this for years: hyphens are word separators, underscores are not. url_slug_guide reads as one token; url-slug-guide reads as three words. Hyphens also survive underlining in UI (an underscore under an underlined link is invisible).
3. Short, but not cryptic
Aim for 3–6 meaningful words. Strip stop words (a, the, and, of...) when the slug stays readable without them:
- Title: "A Complete Guide to the URL Slugs That Google Actually Likes"
- Bad:
a-complete-guide-to-the-url-slugs-that-google-actually-likes - Good:
url-slug-guide
The slug should survive the title being A/B tested. If you tie the slug to the exact title, every headline tweak becomes a redirect decision.
4. ASCII-safe by default
café → cafe, naïve → naive. Accented characters work in modern browsers, but they get percent-encoded the moment they're copied into plain text: caf%C3%A9. That's ugly in search results and a minefield in log processing and old tooling.
5. Never change a published slug (without a 301)
A slug is a contract. If you must rename, keep a redirect from the old slug forever. WordPress does this automatically; if you're rolling your own CMS, store historical slugs in a table and check it on 404.
Where naive implementations die
The classic Stack Overflow one-liner handles English and nothing else:
// ⚠️ the naive version
const slugify = (s) =>
s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
Feed it real-world input and watch:
| Input | Naive output | Problem |
|---|---|---|
Beyoncé's Résumé |
beyonc-s-r-sum |
Accents deleted, not transliterated |
C++ vs C# in 2026 |
c-vs-c-in-2026 |
Meaning destroyed |
你好世界 |
(empty string) | CJK wiped out entirely |
100% 🔥 tips |
100-tips |
Fine — but did you decide that, or did it just happen? |
Fixes, in order of effort:
Transliterate before stripping. String.prototype.normalize("NFKD") decomposes accented characters into base + combining marks, which you can then remove:
const slugify = (s) =>
s
.normalize("NFKD") // é → e + ́
.replace(/[\u0300-\u036f]/g, "") // strip combining marks
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
slugify("Beyoncé's Résumé"); // "beyonce-s-resume"
Handle domain terms with a replacement map before the generic pass: {"c++": "cpp", "c#": "csharp", "&": "and"}. No amount of Unicode cleverness knows that C# is csharp — that's product knowledge.
Decide your CJK policy explicitly. Three legitimate options:
-
Keep the script —
/blog/你好世界is valid and Google indexes it fine; it just percent-encodes when copied (%E4%BD%A0%E5%A5%BD...). Right choice for a zh/ja/ko-audience site. -
Transliterate — pinyin/romaji via a library (
pinyin,kuroshiro). Readable ASCII, but lossy and adds a dependency. -
Fall back to an ID —
/blog/post-8843when the slug would come out empty. Boring and bulletproof.
The bug isn't picking the "wrong" option — it's not picking one and shipping whatever the regex happens to do.
Enforce uniqueness at the database. Two posts titled "Weekly Update" → same slug. Append -2, -3 on collision, and put a unique index on the column so a race condition can't sneak duplicates past you.
Quick sanity checks
Before shipping a slug scheme, paste a few real titles from your content backlog through it — especially ones with apostrophes, ampersands, and non-English words. For one-off checks (a landing page, a campaign URL, a colleague's draft) I keep SlugGenerator.app in my bookmarks — paste text, get the slug, no install.
For the pipeline itself:
- ✅
My Post!!!andmy postproduce the same slug (idempotent normalization) - ✅ Empty-after-stripping input falls back to something (ID, date) instead of
"" - ✅ Changing a title does not silently change the slug of a published post
- ✅ Old slugs 301 to new ones
- ✅ Unique index on the slug column
TL;DR
- Lowercase + hyphens + ASCII, 3–6 words, stop words dropped
-
normalize("NFKD")for accents, replacement map for domain terms, explicit CJK policy - Slugs are permalinks: never mutate without a 301, enforce uniqueness in the DB
- Test with your ugliest real titles, not
"Hello World"— or throw them at SlugGenerator.app and eyeball the output
What's the worst slug bug you've shipped? Mine involved an emoji, a CDN, and a very confused cache key.
Top comments (1)
Great framing—especially the idea that a slug is a contract. For hosted platforms like Blogger, permalink stability matters even more because redirect control can be limited. I try to set a short custom permalink before publishing, keep it stable even if the title changes, and update internal links whenever a migration is unavoidable. It’s also worth testing slug generation with accents, Arabic text, and empty results instead of letting a generic regex decide silently.