What is a URL Slug?
A slug is the human-readable portion of a URL that identifies a page — the part after the last slash. In https://example.com/blog/how-to-write-clean-code, the slug is how-to-write-clean-code. Good slugs are lowercase, short, descriptive, and use hyphens as word separators. Bad slugs look like ?p=1234 or %20article — they hurt SEO, hurt sharing, and hurt trust. Regex is the fastest way to enforce slug rules before a URL ever hits your database.
The Core Patterns
Standard kebab-case slug
/^[a-z0-9]+(-[a-z0-9]+)*$/
// Matches: my-first-post hello-world seo-tips-2026 abc
// Rejects: -bad-slug good-slug- my--post MyPost my_postThis is the SEO gold standard. The [a-z0-9]+ anchor at the start ensures no leading hyphen. The (-[a-z0-9]+)* group repeats zero or more times, requiring at least one alphanumeric after every hyphen — so consecutive hyphens and trailing hyphens are both rejected. Use this unless you have a specific reason.
Slug allowing underscores
/^[a-z0-9]+([-_][a-z0-9]+)*$/
// Matches: my-post my_post hello_world-2026
// Rejects: -- my__post _slug slug_Google recommends hyphens over underscores for SEO (Google treats hyphens as word breaks but underscores as word joiners — hello_world is indexed as one word, hello-world as two). For public URLs use hyphens; use this looser pattern only for internal identifiers.
Snake_case identifier
/^[a-z0-9]+(_[a-z0-9]+)*$/
// Matches: my_variable snake_case_name foo_bar_123
// Rejects: my-variable _underscore my__variableNot a URL slug per se, but the same shape shows up as database column names, Python variable names, and Rails-style identifiers.
Length-constrained slug (3–60 chars for SEO)
/^[a-z0-9](-?[a-z0-9]){2,58}$/
// Matches: 3-char minimum, 60-char max, kebab-case only
// abc my-post this-is-a-good-length-slug-for-seoSlug + numeric ID suffix (WordPress-style)
/^[a-z0-9]+(-[a-z0-9]+)*-\d+$/
// Matches: my-post-123 hello-world-42
// Common for CMSes that append a post ID to guarantee uniqueness.Unicode slug (accepts letters from any script)
/^[\p{L}\p{N}]+(-[\p{L}\p{N}]+)*$/u
// Matches: hola-mundo мой-пост 日本語-記事
// Requires the /u (Unicode) flag in JavaScript and Python re.ULanguage-Specific Usage
JavaScript
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
function isValidSlug(str) { return SLUG.test(str); }
isValidSlug('my-first-post'); // true
isValidSlug('Hello-World'); // false — uppercase
isValidSlug('-bad-slug'); // false — leading hyphen
isValidSlug('my--post'); // false — double hyphen
// Full slugify pipeline
function slugify(str) {
return str
.toLowerCase()
.normalize('NFD') // Decompose accents
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
.replace(/[^a-z0-9]+/g, '-') // Non-alphanumeric → hyphen
.replace(/^-+|-+$/g, '') // Trim leading/trailing hyphens
.slice(0, 60); // Enforce SEO length cap
}
slugify('¡Hola, Mundo! 2026'); // 'hola-mundo-2026'
slugify('Café & Croissant'); // 'cafe-croissant'Python
import re
import unicodedata
SLUG = re.compile(r'^[a-z0-9]+(-[a-z0-9]+)*$')
def is_valid_slug(s: str) -> bool:
return bool(SLUG.match(s))
def slugify(s: str) -> str:
s = s.lower()
s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii')
s = re.sub(r'[^a-z0-9]+', '-', s)
s = s.strip('-')
return s[:60]
slugify('Hello, World! 2026') # 'hello-world-2026'
is_valid_slug('my-first-post') # True
# Or use python-slugify:
# from slugify import slugify
# slugify('Hello World')PHP
function isValidSlug(string $s): bool {
return (bool) preg_match('/^[a-z0-9]+(-[a-z0-9]+)*$/', $s);
}
function slugify(string $s): string {
$s = mb_strtolower($s, 'UTF-8');
$s = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
$s = preg_replace('/[^a-z0-9]+/', '-', $s);
$s = trim($s, '-');
return substr($s, 0, 60);
}
// Laravel has Str::slug() built in:
// Str::slug('Hello World'); // 'hello-world'Common Pitfalls
Reserved routes collision
Regex passes admin and api as valid slugs. If your app has routes at /admin and /api, a user-created slug of admin would collide. Always check the slug against a blocklist AFTER regex validation: ['admin', 'api', 'login', 'register', 'sitemap', 'robots', 'dashboard'].
Numeric-only slugs look like IDs
12345 passes the standard slug regex. If your app has routes like /post/12345 for numeric IDs AND /post/my-slug for slugs, purely numeric slugs cause ambiguity. Add a rule that at least one letter must be present: /^(?=.*[a-z])[a-z0-9]+(-[a-z0-9]+)*$/.
Uniqueness is a DB problem, not a regex problem
Regex validates format. It cannot check whether the slug is already taken. Always add a UNIQUE constraint at the database level, and if a collision occurs append a numeric suffix (my-post-2, my-post-3) or a short random token.
Emoji and symbols disappear silently
slugify('My 🎉 Party!') returns my-party — the emoji is dropped without warning. If you WANT to preserve emoji-like content, use a Unicode-aware slugifier (JS: @sindresorhus/slugify) or transliterate emoji to text (🎉 → party-popper).
URL length matters for SEO
Google truncates URLs beyond about 60 characters in mobile search snippets. Overly long slugs look spammy and hurt CTR. Aim for 3–5 words maximum. If your title is longer, extract the key phrase — ten-python-tricks is better than the-top-ten-python-tricks-every-developer-should-know-in-2026.
Slug Regex Cheatsheet
| Goal | Pattern | Matches |
|---|---|---|
| Standard slug | /^[a-z0-9]+(-[a-z0-9]+)*$/ | my-post-2026 |
| Hyphen + underscore | /^[a-z0-9]+([-_][a-z0-9]+)*$/ | my-post, my_post |
| Length-capped | /^[a-z0-9](-?[a-z0-9]){2,58}$/ | 3–60 chars |
| Requires a letter | /^(?=.*[a-z])[a-z0-9]+(-[a-z0-9]+)*$/ | rejects 12345 |
| Unicode slug | /^[\p{L}\p{N}]+(-[\p{L}\p{N}]+)*$/u | multi-script |
Testing Your Slug Regex
Use the live Regex Tester above with these test strings:
- Match:
my-first-post,hello-world-2026,abc - Reject (leading hyphen):
-bad-slug - Reject (trailing hyphen):
bad-slug- - Reject (double hyphen):
my--post - Reject (uppercase):
My-Post,HELLO - Reject (spaces/symbols):
my post,hello!
Common Mistakes When Writing Slug Regex
- Allowing uppercase. Slugs must be lowercase. Always lowercase input BEFORE regex validation so the regex can be strict.
- Not anchoring the pattern. Without
^and$,abc-BAD-defmatches because the middle contains a valid subsequence. Always anchor for validation. - Forgetting reserved names. Regex says nothing about route collisions. Add a blocklist check after regex passes.
- Using * instead of +.
[a-z0-9]*allows empty strings.[a-z0-9]+requires at least one character. - No length limit. A 500-character slug passes basic format regex but destroys SEO. Add
{1,60}quantifiers or check.lengthin code.
Performance Notes
Slug regexes are extremely fast — bounded character classes with no backtracking. Even the Unicode-property version runs in microseconds. Validating a slug on every keystroke in a form is a non-issue performance-wise. If you're slugifying millions of titles in a batch job, precompile the regex once (module-level constant) rather than recompiling per call.