Check whether your own slugs are valid

Paste the slugs you actually have — or a title you are turning into one — into the tester below and see which pass this pattern’s shape and which fail, and why. The sample already loaded mixes three valid slugs with five that fail for five different reasons — stray capitals, a doubled hyphen, a leading hyphen, a trailing hyphen, and a space. The copy-paste pattern, explained token by token, and the harder problem of generating a slug from an arbitrary title, follow underneath.

/ / gm
Flags

    Your pattern and test text never leave this tab. Matching runs in a same-origin Web Worker with a timeout, so a draft title or an unpublished slug is never sent anywhere. That protection matters more than it looks: a single regular expression once caused a real, global outage — the timeout above is what keeps this page safe from the same failure mode.

    ^[a-z0-9]+(?:-[a-z0-9]+)*$

    What a slug is, and what this pattern checks

    A slug is the human-readable segment of a URL path that identifies one piece of content — the my-great-post in example.com/blog/my-great-post. Convention, not any standard, dictates its shape: lowercase, alphanumeric segments joined by single hyphens, with no leading or trailing hyphen and no doubled hyphen. This pattern validates that shape; it does not generate a slug from a title, which is a separate, harder problem covered further down.

    Token by token

    TokenMeaning
    ^Start of the string
    [a-z0-9]+One or more lowercase letters or digits — the first segment
    (?:-[a-z0-9]+)*Zero or more repetitions of: a single literal hyphen, then another run of one or more lowercase letters or digits
    $End of the string

    The group is non-capturing ((?:…), not (…)) because nothing here needs to read back which specific segment matched — it exists purely to give * something to repeat: “hyphen, then more characters”, as a unit, zero or more times. That single design choice is what makes the whole shape work: a hyphen can only ever appear immediately followed by at least one alphanumeric character, which is exactly what rules out a leading hyphen, a trailing one, and a doubled one, all in one small pattern rather than three separate checks.

    What it matches

    • hello-world — the ordinary case.
    • abc123-def-456 — digits mixed into any segment.
    • a — a single character is a valid slug; there is no minimum length beyond “at least one character”.

    What it deliberately does not match, and why

    InputVerdictWhy
    Hello-WorldRejectedUppercase letters — the character class is a-z only, deliberately, since slugs conventionally normalise case for consistent, case-insensitive-looking URLs
    hello--worldRejectedA doubled hyphen — the non-capturing group requires at least one alphanumeric character between any two hyphens, so two hyphens in a row have nothing to separate
    -leading-hyphenRejectedThe ^ anchor requires the string to start with [a-z0-9]+, not a hyphen
    trailing-RejectedSymmetric with the above: the $ anchor requires the string to end with an alphanumeric run, not a hyphen
    has spacesRejectedA space is not in the character class at all — spaces should already be converted to hyphens before slug validation, not after
    caféRejectedAn accented character outside a-z. See below for why transliterating it is a separate step from validating the result

    Validating a slug is not the same as generating one

    This pattern answers “is this string already slug-shaped”. Getting from an arbitrary title — My Great Post!, or Café Résumé, or a title written entirely in a non-Latin script — to a slug that passes this pattern is a different job, and a considerably harder one once non-ASCII text is involved. The mechanical part is straightforward: lowercase the string, strip characters outside the allowed set, and collapse runs of whitespace or removed characters into single hyphens. The judgement call is what happens to a character like é or ü or a Cyrillic or CJK character, since none of them fit [a-z0-9]. Two defensible strategies exist, and they produce very different slugs from the same title:

    • Transliterate: convert é to e, ü to u, and so on, using a mapping table or a library, so Café becomes cafe. This keeps the slug ASCII and this pattern happy, at the cost of losing the distinction between accented and unaccented forms.
    • Keep it Unicode: many modern platforms (including most blogging software) allow slugs containing the original non-Latin letters, relying on the browser and server to handle Unicode paths and percent-encoding correctly, rather than forcing everything through an ASCII transliteration. A pattern permitting this needs a Unicode property escape such as \p{L} in place of a-z, which requires the u flag and is governed by UTS #18’s rules for how a regex engine should classify Unicode letters — a materially different pattern from the ASCII-only one on this page, and one that then has to define its own rules for what “lowercase” means across scripts that do not have letter case at all (most CJK scripts, for instance).

    This page’s pattern deliberately takes the first, ASCII-only stance, because it is the more common convention on the open web and the simpler one to reason about; if your platform has chosen the second, you need a different pattern than the one above, not a variation of it.

    Flavour: this is JavaScript’s regex

    This pattern targets JavaScript’s built-in RegExp, per ECMA-262. It uses only literals, a character class, +, * and a non-capturing group — every one of those is identical in PCRE and Python’s re, so this exact text ports unchanged. The Unicode variant discussed above, using \p{L}, needs the u flag in JavaScript specifically; PCRE spells the equivalent property class similarly but with its own flag conventions. See the cookbook’s flavour-honesty section for the rest of the differences.

    Worked examples

    1. A minimal ASCII slugify function

    function slugify(title) {
      return title
        .toLowerCase()
        .normalize('NFKD').replace(/[\u0300-\u036f]/g, '') // strip accent marks: é -> e
        .replace(/[^a-z0-9]+/g, '-')                       // anything else becomes a hyphen
        .replace(/^-+|-+$/g, '');                          // trim leading/trailing hyphens
    }
    slugify('Café Résumé: My Great Post!'); // -> "cafe-resume-my-great-post"

    Run the output through this page’s pattern as a final assertion before storing it — the slugify step above is the generator, the regex on this page is the validator, and keeping them separate means a bug in one does not silently corrupt the other’s guarantee.

    2. Enforcing uniqueness is not this pattern’s job either

    Two different posts can both slugify to my-great-post. This pattern has no way to know that, since it validates one string in isolation. Uniqueness needs a database constraint or a lookup against existing slugs, with a numeric or dated suffix (my-great-post-2) appended on collision — itself still a valid slug under this same pattern.

    3. A slug inside a full URL

    This pattern validates one path segment in isolation, not a whole URL. If you need to confirm a full link is well-formed and then pull just the last segment out to validate as a slug, see the dedicated URL page for the broader-shaped pattern and why parsing the path with the URL constructor beats extending either regex to do both jobs at once.

    4. Rejecting a slug that collides with a reserved route

    A slug of admin, api, or new is perfectly valid against this pattern, but any of them may collide with a fixed route your application already defines — /blog/new meaning “create a post” rather than a post titled “new”. This pattern has no way to know your route table, and should not be extended to try — keep an explicit denylist of reserved slugs in your own routing code and check it separately, after this pattern has already confirmed the shape is otherwise valid.

    Using this pattern in your own code

    The pattern uses only a character class, +, * and a non-capturing group — all standard, so the text carries over unchanged.

    // JavaScript
    const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
    SLUG_RE.test('hello-world');  // true
    SLUG_RE.test('Hello-World');  // false
    # Python's re module — identical pattern text
    import re
    SLUG_RE = re.compile(r'^[a-z0-9]+(?:-[a-z0-9]+)*$')
    bool(SLUG_RE.match('abc123-def-456'))  # True
    # grep -P, checking every generated slug in a CSV export in one pass
    grep -Pv '^[a-z0-9]+(?:-[a-z0-9]+)*$' slugs.csv   # prints every INVALID row

    The last example flips the usual sense with grep’s -v flag deliberately: for an audit of already-generated slugs, the useful output is the list of rows that fail the pattern, not the (presumably much longer) list that pass it.

    Common mistakes when adapting this pattern

    • Making the whole pattern one character class instead of a repeated group. ^[a-z0-9-]+$ looks equivalent at a glance but is not: it happily accepts -leading, trailing- and double--hyphen, because a bare hyphen is just another allowed character with no positional constraint. The non-capturing-group structure on this page ([a-z0-9]+(?:-[a-z0-9]+)*) is what actually rules those three cases out, by requiring a hyphen to always sit between two alphanumeric runs.
    • Allowing underscores “to be safe”. Adding _ to the character class accepts my_post alongside my-post, which then means two different-looking URLs can point at conceptually the same slug depending on which one a title was slugified into — a consistency problem more than a validity one. Most platforms standardise on hyphens only and convert underscores to hyphens during generation, rather than accepting both at validation time.
    • Validating before slugifying instead of after. Running this pattern against a raw, unprocessed title will reject almost everything, since real titles contain spaces, punctuation and capital letters by default. This pattern is the last step, checking the output of a slugify function (worked example 1 above), not a filter applied to the raw input a person actually types.

    Frequently asked questions

    Should slugs allow uppercase letters?

    Convention says no. Two URLs differing only in case (/Post versus /post) invite duplicate-content confusion for search engines and case-sensitive-server mismatches; lowercasing everything sidesteps both. Some frameworks case-fold URLs automatically, which only reinforces treating slugs as lowercase-only from the start.

    Can a slug be all digits, like 12345?

    Yes, this pattern accepts it — [a-z0-9]+ allows digits with no letters at all. Whether that is a good idea for your application (it can be confused with a numeric database ID in some routing schemes) is a separate design decision this pattern does not make for you.

    Does this pattern support Unicode slugs?

    No, by design — see “validating a slug is not the same as generating one” above for the ASCII-versus-Unicode trade-off and the \p{L} alternative if your platform allows non-Latin slugs.

    What is the maximum slug length this pattern allows?

    None — + and * are unbounded. If you need a length cap (many platforms cap slugs around 100–200 characters for practical URL-length and readability reasons), enforce it separately, either with {1,80}-style bounded quantifiers in place of +/*, or a plain length check on the string before or after the regex.

    Why does a-b-c pass but a--b fail?

    Because every hyphen in a-b-c is immediately followed by at least one alphanumeric character, satisfying (?:-[a-z0-9]+)* for each one. In a--b, the second hyphen is immediately followed by another hyphen, not a letter or digit, so that repetition of the group cannot match — there is nothing for [a-z0-9]+ to consume right after it.

    Should I validate on every keystroke while someone edits a slug field?

    Usually not directly against this exact pattern — a slug being typed is invalid at almost every intermediate stage (a single trailing hyphen while someone is about to type the next word, for instance). Auto-generating the slug from a title field as the person types, using a function like the one in worked example 1, and validating only the final result, is the friendlier pattern in practice.

    Can a slug be purely numeric, and is that a problem later?

    This pattern accepts 12345 as a valid slug, since [a-z0-9]+ has no requirement that a letter appear anywhere. It can become a problem if your routing also has numeric IDs in the same position — a request for /posts/12345 could then be ambiguous between “post with slug 12345” and “post with ID 12345”. If your application distinguishes the two, resolve that ambiguity in your routing logic explicitly, rather than trying to teach this pattern to reject all-numeric strings, which would also break any post genuinely titled entirely in numbers.

    Is my draft title or slug sent anywhere when I test it here?

    No. Matching runs inside a same-origin Web Worker, and there is no upload endpoint anywhere on this page — an unpublished title never leaves your browser.

    Limitations

    • ASCII lowercase and digits only; no built-in Unicode support (see the section above for the trade-off and the \p{L} alternative).
    • Validates shape only — cannot detect a duplicate slug, which needs a database or lookup check.
    • No length limit; add a bounded quantifier or separate length check if your platform needs one.
    • Does not generate a slug from a title; pair with a transliteration/normalisation step like worked example 1 for that half of the job.

    Related