June 20262 min read
A slug is a decision, not a derivation
Deriving a URL from a title looks like a pure function. It is actually a small pile of product decisions wearing a regular expression.
- Engineering
- Tooling
slugify is the kind of function you write in four minutes and maintain for four years.
export function slugify(input: string): string {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
Ship that and you will be back within a month. Here is everything it quietly gets wrong.
Accents
Café becomes caf. The é is not in a-z0-9, so it is replaced, and the trailing hyphen is trimmed. The word is now a different word.
The fix is to decompose first, so accented characters split into a base letter plus a combining mark, then strip the marks:
input.normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
Café Déjà Vu becomes cafe-deja-vu. Not perfect for every language — German speakers will tell you ü should become ue — but it is right far more often than dropping the letter.
Titles with no letters at all
!!! produces the empty string. The empty string is a valid slug as far as your database is concerned, and it will happily save one. Then /blog/ becomes a post page, and your route matching does something you did not plan for.
Every slug function needs a floor. Mine returns untitled.
Collisions
Two posts called "Weeknotes" produce the same slug, and the second insert fails on a unique constraint. The obvious fix is to append a counter — but the obvious implementation of that has a bug:
export async function uniqueSlug(
input: string,
isTaken: (candidate: string) => Promise<boolean>,
): Promise<string> {
const base = slugify(input) || "untitled";
let candidate = base;
let counter = 1;
while (await isTaken(candidate)) {
counter += 1;
candidate = `${base}-${counter}`;
}
return candidate;
}
The subtlety is in isTaken. When you are editing a post, its own slug is taken — by itself. If isTaken does not exclude the row being edited, every save appends another number, and a post you edit five times ends up at weeknotes-6.
That is why the check is a callback rather than a query. The caller knows which row to exclude; the helper does not need to.
The decision nobody makes on purpose
Here is the real question: when the title changes, does the slug change?
Change it, and every existing link breaks. Keep it, and a post titled "Ten things" lives forever at /blog/nine-things.
There is no correct answer, only a choice. What is not acceptable is failing to make it, which is what happens when slug is silently recomputed from title on every save.
I derive the slug once, on creation, then leave it alone and let it be edited by hand. It stops being a derivation and becomes what it always was: a decision, with a default.