How to Slugify a String in JavaScript: A Tested Guide
By Abdessamad Bettal· · 7 min read
Key takeaways
- Lowercase, map the letters Unicode can't decompose (ß, ł, ø, æ), normalize to NFKD, strip combining marks with /\p{M}/gu, then replace every run of non-alphanumerics with a hyphen.
- NFKD (not NFD) is what turns typographic ligatures like fi into fi. In our tests both popular npm packages turned financial into nancial.
- No dictionary-free function can romanize Cyrillic, CJK, or Arabic well. Use a transliteration library, a Unicode slug, or an ID fallback, and never ship an empty slug.
- Truncate at the last separator before your limit so you never cut a word in half, and append -2, -3 for collisions the way WordPress does.
Turning "Crème Brûlée: A How-To" into creme-brulee-a-how-to looks like a one-line job. It isn't, and most one-liners copied from Stack Overflow quietly produce broken slugs on real-world titles. This guide builds a slugify function step by step, then tests it against the two most popular npm packages on the cases that usually go wrong.
The function
If you just need the code, here it is. Every line is explained below.
const CHAR_MAP = { ß: "ss", æ: "ae", ø: "o", đ: "d", ł: "l", œ: "oe", þ: "th", ð: "d", ı: "i" };
export function slugify(input, { separator = "-", maxLength = 60 } = {}) {
let slug = input
.toLowerCase()
.replace(/[ßæøđłœþðı]/g, (ch) => CHAR_MAP[ch]) // letters with no decomposition
.normalize("NFKD") // é → e + ◌́ , fi → fi
.replace(/\p{M}/gu, "") // drop the combining marks
.replace(/['’]/g, "") // don't → dont
.replace(/&/g, " and ") // rock & roll → rock and roll
.replace(/[^a-z0-9]+/g, separator) // everything else → separator
.replace(/^[-_]+|[-_]+$/g, ""); // trim separators at the ends
if (slug.length > maxLength) {
const cut = slug.slice(0, maxLength + 1);
const lastSep = cut.lastIndexOf(separator);
slug = lastSep > 0 ? cut.slice(0, lastSep) : slug.slice(0, maxLength);
}
return slug;
}
It has no dependencies and runs in every modern browser, Node.js, Deno, Bun, and edge runtimes. String.prototype.normalize and Unicode property escapes (\p{M}) have been universally supported for years.
Why the one-liners fail
Two versions show up in almost every codebase. The naive version only swaps spaces: s.toLowerCase().replace(/\s+/g, "-"). The basic version replaces everything outside [a-z0-9]. Here's what they produce on real titles:
| Input | Naive | Basic | This function |
|---|---|---|---|
Hello, World! |
-hello,-world!- |
hello-world |
hello-world |
Crème Brûlée: A How-To |
crème-brûlée:-a-how-to |
cr-me-br-l-e-a-how-to |
creme-brulee-a-how-to |
Don't Stop Me Now |
don't-stop-me-now |
don-t-stop-me-now |
dont-stop-me-now |
Rock & Roll |
rock-&-roll |
rock-roll |
rock-and-roll |
Straße in Łódź |
straße-in-łódź |
stra-e-in-d |
strasse-in-lodz |
The naive version leaks punctuation and non-ASCII characters into the URL, where they get percent-encoded the moment someone shares the link. The basic version is safe, but it treats every accented letter as a word break, so crème turns into cr-me.
Building it step by step
1. Lowercase first
URLs are case-sensitive on most servers, so /Blog and /blog are different resources. Lowercasing first also means every later step only has to handle lowercase characters. That's why the character map has no uppercase entries: "Ł".toLowerCase() is already "ł".
2. Map the letters Unicode can't decompose
This is the step most implementations skip. Unicode normalization splits a character only when it has a defined decomposition: é becomes e plus a combining acute accent. But ß, ł, ø, æ, œ, þ, ð, and the dotless ı are letters in their own right. They have no decomposition, so they survive normalization unchanged and are then deleted by the [^a-z0-9] step. That's how "Straße in Łódź" becomes stra-e-in-d.
A small explicit map fixes the common European cases. Map them before normalizing, so the result goes through the same pipeline as every other character.
3. Normalize to NFKD, then strip marks
"crème".normalize("NFKD"); // "crème": e followed by a combining grave accent
After decomposition, /\p{M}/gu removes every combining mark (Unicode category M). This is more thorough than the [̀-ͯ] range you'll often see, which only covers the main combining block and misses marks used in Vietnamese and other scripts.
Why NFKD and not NFD? The K stands for compatibility decomposition. It also unfolds typographic ligatures (fi → fi, fl → fl), full-width characters, and superscripts. Text pasted from PDFs and word processors is full of ligatures, and NFD leaves them alone.
4. Handle apostrophes and ampersands as words, not separators
Deleting apostrophes before the separator step keeps contractions together: don't → dont, not don-t. Replacing & with and keeps meaning that would otherwise vanish: rock-and-roll, not rock-roll. Note that both quote characters are handled. Titles typed into a CMS often contain the curly ’ (U+2019), not the straight '.
5. Collapse everything else into one separator
/[^a-z0-9]+/g matches runs of anything that isn't a lowercase ASCII letter or digit: spaces, punctuation, emoji, em dashes. Because of the +, a run of any length becomes a single separator, so Hello, World! doesn't produce hello----world. The last replace trims separators left at either end.
6. Truncate at a word boundary
Search results truncate long URLs, and long slugs are harder to share (how long a URL can be covers the exact limits). A plain slice(0, 60) can cut a word in half. Instead, the function takes the first maxLength + 1 characters and cuts back to the last separator. The + 1 matters: if the character right after the limit is a separator, the word before it is complete and gets kept.
Limit applied to an 82-character title:
10 Ways to Improve Your Café's Wi-Fi — And Why It Matters For Small Business Owners
→ 10-ways-to-improve-your-cafes-wi-fi-and-why-it-matters-for (58 chars)
That result also shows the limits of automatic truncation: it ends on the dangling word for. Automatic truncation is a safety net, not editing. For pages that matter, write the slug by hand around the search phrase, as covered in the slug best practices.
Tested against the npm packages
We ran the same inputs through the two most-downloaded slug packages, slugify 1.6.9 (called with { lower: true, strict: true }) and @sindresorhus/slugify 3.0.1 (defaults), on Node.js 22 in October 2026:
| Input | This function | slugify | @sindresorhus/slugify |
|---|---|---|---|
Crème Brûlée: A How-To |
creme-brulee-a-how-to |
creme-brulee-a-how-to |
creme-brulee-a-how-to |
Straße in Łódź |
strasse-in-lodz |
strasse-in-lodz |
strasse-in-lodz |
financial flow |
financial-flow |
nancial-ow |
nancial-ow |
Tiếng Việt cho người mới |
tieng-viet-cho-nguoi-moi |
tieng-viet-cho-nguoi-moi |
tieng-viet-cho-nguoi-moi |
🚀 Launch Day |
launch-day |
launch-day |
launch-day |
C++ vs C# |
c-vs-c |
c-vs-c |
c-vs-c |
Привет мир |
(empty) | privet-mir |
privet-mir |
東京タワー |
(empty) | (empty) | (empty) |
Three findings stand out:
- Both packages drop typographic ligatures.
financialbecomesnancial. They rely on character maps instead of NFKD normalization, and the maps don't include ligatures. If your titles come from copy-pasted documents, normalize to NFKD before calling either library. - The packages win on Cyrillic. They ship transliteration tables, so
Привет мирbecomesprivet-mir. A dictionary-free function can't do that without a large map of its own. - Nobody handles Japanese or Chinese. Kanji can't be romanized without a dictionary, because the same character has different readings in different words. Every implementation returns an empty string.
The packages also have locale behavior worth knowing about. @sindresorhus/slugify transliterates German umlauts to ae, oe, ue by default. slugify only does that when you pass { locale: "de" }. Without it, Ä Ö Ü becomes a-o-u.
And note C++ vs C# → c-vs-c in every column. Symbols that carry meaning need custom replacements. With @sindresorhus/slugify:
slugify("C++ vs C#", { customReplacements: [["++", " plus plus "], ["#", " sharp "]] });
// → "c-plus-plus-vs-c-sharp"
What to do about non-Latin titles
An empty slug is the one result you must never ship, since it produces /blog/ or a database uniqueness error. You have three options:
- Transliterate with a library if you publish in Cyrillic, Greek, or other scripts with a standard romanization. This is the main reason to take on a dependency.
- Keep Unicode in the slug. Modern browsers display
/привет-мирcorrectly in the address bar, and search engines index it. The trade-off is that it becomes/%D0%BF%D1%80%D0%B8...when pasted into email, chat, or plain-text contexts. The international URLs guide covers when that's acceptable. A Unicode-preserving variant only needs a different character class:
const unicodeSlug = (s) =>
s.toLowerCase().normalize("NFKC").replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
unicodeSlug("Привет мир"); // "привет-мир"
unicodeSlug("東京 タワー 2026"); // "東京-タワー-2026"
- Fall back to an ID when the result is empty:
slugify(title) || `post-${id}`. That's ugly but correct, and an editor can replace it with a hand-written slug.
Making slugs unique
Two posts titled "Hello World" need two URLs. The convention, used by WordPress among others, is a numeric suffix:
function uniqueSlug(base, taken) {
if (!taken.has(base)) return base;
let n = 2;
while (taken.has(`${base}-${n}`)) n++;
return `${base}-${n}`;
}
uniqueSlug("hello-world", new Set(["hello-world", "hello-world-2"])); // "hello-world-3"
In production, enforce uniqueness with a unique index in the database and retry on conflict. A check-then-insert in application code alone has a race condition when two editors save at the same moment.
TypeScript version
The function is already type-safe. Adding types just documents the options:
const CHAR_MAP: Record<string, string> = {
ß: "ss", æ: "ae", ø: "o", đ: "d", ł: "l", œ: "oe", þ: "th", ð: "d", ı: "i",
};
export interface SlugifyOptions {
separator?: "-" | "_";
maxLength?: number;
}
export function slugify(
input: string,
{ separator = "-", maxLength = 60 }: SlugifyOptions = {}
): string {
// …same body as above
}
It compiles under strict: true with an ES2018 or later target, which is what Unicode property escapes require.
Hyphens or underscores?
Pass { separator: "_" } if your platform demands snake_case, but for public URLs, use hyphens. Google treats hyphens as word separators and underscores as word joiners. The full reasoning is in hyphens vs. underscores.
Try it without writing code
The slug generator on this site uses the same approach (lowercasing, an explicit character map, NFKD normalization, and mark stripping), extended with Cyrillic, Greek, and Arabic transliteration tables, plus optional stop-word removal. To convert a whole content export at once, paste the titles or upload a CSV to the bulk slug generator. Both run entirely in your browser.
Frequently asked questions
- What is the simplest way to slugify a string in JavaScript?
- For ASCII-only input: str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''). It fails on accented text (Crème becomes cr-me), so for real titles add .normalize('NFKD').replace(/\p{M}/gu, '') before the replace.
- Should I use the slugify npm package or write my own function?
- Write your own if your titles are English or Western European. It's about 15 lines with no dependency. Use a library such as slugify or @sindresorhus/slugify if you need Cyrillic, Greek, or other script transliteration, which requires large character maps.
- Why does my slugify function delete characters like ß or Ł?
- Unicode normalization only splits letters that have a defined decomposition, like é into e plus an accent. ß, Ł, Ø, Æ, and Œ are standalone letters with no decomposition, so after you strip non-ASCII characters they disappear. Map them explicitly first: ß to ss, ł to l, ø to o, æ to ae, œ to oe.
- How do I make slugs unique in JavaScript?
- Check the generated slug against the set of slugs already in use, and if it's taken, append -2, then -3, and so on until it's free. Do the check in the database transaction that saves the record, not only in the browser, or two simultaneous saves can still collide.