Generate a slug and keep it unique
slugify turns "Café & Résumé" into cafe-resume. What it cannot know is that
you already have a cafe-resume. This recipe pairs
slugify with unique to
produce a slug that is safe for a URL and free.
The code
import { unique } from '@rtorcato/js-common/arrays'
import { slugify } from '@rtorcato/js-common/strings'
const MAX_SLUG_LENGTH = 60
/**
* Slugifies a title and appends `-2`, `-3`, … until the result is not taken.
* `taken` may contain duplicates — they are collapsed before the lookup.
*/
export function uniqueSlug(title: string, taken: string[] = []): string {
const base = trimSlug(slugify(title)) || 'untitled'
const used = new Set(unique(taken))
if (!used.has(base)) return base
let suffix = 2
while (used.has(`${base}-${suffix}`)) suffix++
return `${base}-${suffix}`
}
/** Cuts a slug to length without leaving a trailing or half-eaten hyphen. */
function trimSlug(slug: string): string {
if (slug.length <= MAX_SLUG_LENGTH) return slug
return slug.slice(0, MAX_SLUG_LENGTH).replace(/-[^-]*$/, '')
}
uniqueSlug('Café & Résumé') // "cafe-resume"
uniqueSlug('Hello World!', ['hello-world']) // "hello-world-2"
uniqueSlug('Hello World!', ['hello-world', 'hello-world-2']) // "hello-world-3"
uniqueSlug('🎉🎉🎉') // "untitled" — nothing slug-safe survives
The || 'untitled' matters: slugify strips everything that is not
[a-z0-9-], so a title made entirely of emoji, CJK characters, or punctuation
comes back as an empty string.
Slugging a whole batch
Importing a list of posts hits a second kind of collision — two titles in the same batch. Feed each result back into the taken list:
export function slugifyAll(titles: string[], taken: string[] = []): string[] {
const seen = [...taken]
return titles.map((title) => {
const slug = uniqueSlug(title, seen)
seen.push(slug)
return slug
})
}
slugifyAll(['Hello World', 'Hello, world!', 'Hello World'])
// ["hello-world", "hello-world-2", "hello-world-3"]
Where taken comes from
Reading every existing slug into memory works up to a few thousand rows. Beyond that, query the ones that could collide:
SELECT slug FROM posts WHERE slug = $1 OR slug LIKE $1 || '-%';
Two requests can generate hello-world-2 at the same time and both pass the
check. Keep a UNIQUE constraint on the column, and retry with the next suffix
when the insert fails. The helper avoids collisions; the constraint prevents
them.
If suffixes are not worth the round trip, append a short random token instead — collisions become improbable rather than impossible, and no lookup is needed:
import { randomString } from '@rtorcato/js-common/random'
// Pass a lowercase alphabet — the default charset includes A–Z.
const token = randomString(6, 'abcdefghijklmnopqrstuvwxyz0123456789')
const slug = `${slugify(title)}-${token}` // "hello-world-a7f3k2"