VibeWeek
Home/Grow/Slugs & URL Handling: Stable, SEO-Friendly URLs Without Tears

Slugs & URL Handling: Stable, SEO-Friendly URLs Without Tears

⬅️ Day 6: Grow Overview

If your SaaS has user-generated content with public URLs in 2026 — blog posts, profile pages, product listings, documents, dashboards, anything addressable — slugs are how you turn "page 12345" into "/posts/how-to-ship-on-friday." The naive implementation breaks: collisions when two posts have the same title; ugly URLs when titles include emojis or non-ASCII; broken links when titles get edited; SEO disasters when content moves and old URLs return 404. Most indie SaaS slaps lodash.kebabCase(title) on insert and ships, then six months in: 4 URL conflicts, 200 404s in Search Console, half the posts have untitled-2 as the slug.

A working slug strategy answers: how do you generate (kebab-case + dedup), what makes a slug unique (per-tenant? per-author? globally?), how do you handle non-ASCII (transliterate or preserve?), how do you handle title edits (slug rename + 301 redirect; never silently change), how do you handle deletes (soft delete with redirect), and how do you handle re-uses (don't reuse a slug from a deleted item).

This guide is the implementation playbook for slugs and clean URL handling. Companion to SEO Setup, Programmatic SEO, Internationalization, Soft Delete vs Hard Delete, and API Versioning.

Why Slugs Matter

Get the failure modes clear first.

Help me understand slug failure modes.

The 8 categories:

**1. Collisions**
Two posts titled "How to Ship Faster" → both want slug "how-to-ship-faster."
First wins; second errors or gets weird suffix.

**2. Ugly URLs**
Title contains emojis, accents, smart quotes, Cyrillic.
Result: %20%E2%9C%85 in URL or invalid characters.

**3. Title edits breaking URLs**
Author renames "Ship Fast" → "Ship Faster"; URL silently changes.
External backlinks break; SEO drops; user bookmarks 404.

**4. Slug typos**
Author types "shp-fast"; URL is /posts/shp-fast forever; embarrassing.

**5. Stop-word stuffing**
"How to Do A Better Thing With The Tool I Like" → 80-char URL of mostly stop-words.

**6. Internationalization (i18n)**
Title in Korean / Arabic / Chinese: how do you slug?
Transliterate? Preserve? URL-encode?

**7. Reserved-word collisions**
User titles a post "API"; slug "/api" conflicts with /api routes.
Or "admin", "settings", "login".

**8. Reuse confusion**
Post deleted; later, new post with same title; gets same slug.
Old URL now points to NEW content; users confused; SEO disasters.

For my product:
- Where slugs appear (URLs, IDs, references)
- User base (i18n needs?)
- SEO importance

Output:
1. Top failure modes
2. Risk priorities
3. Implementation order

The biggest unforced error: letting a slug change silently when the title changes. External references (links, social shares, search engines) all break. Old URL must redirect; new URL must keep working; both must not collide with anything.

Slug Generation Patterns

Help me generate slugs right.

The pipeline:

Title → normalize → ASCII-fold → kebab-case → trim → dedupe → store


Each step:

**Step 1: Normalize**

```typescript
function normalize(title: string): string {
  return title
    .normalize('NFKD')          // Decompose unicode (é → e + combining mark)
    .replace(/[̀-ͯ]/g, '') // Remove combining marks
    .toLowerCase()
    .trim();
}

cafécafe; Naïvenaive. Preserves readability.

Step 2: ASCII-fold non-Latin

import slugify from 'slugify';
// or @sindresorhus/slugify (more comprehensive)

slugify('Hello World!', { lower: true, strict: true });
// 'hello-world'

slugify('Привет мир', { lower: true, strict: true });
// 'privet-mir' (transliterated)

For Korean / Chinese / Arabic: choose between transliterate (loose) or preserve-with-encoding (strict).

Step 3: Kebab-case

Replace spaces and non-allowed chars with hyphens; collapse multiples.

function kebab(s: string): string {
  return s
    .replace(/[^a-z0-9]+/g, '-')  // Non-alpha-num → hyphen
    .replace(/^-+|-+$/g, '')      // Trim leading/trailing hyphens
    .replace(/-+/g, '-');         // Collapse multiple
}

Step 4: Length cap

Cap at 80 chars (SEO best practice; Google truncates ~75-80).

function cap(s: string, max = 80): string {
  if (s.length <= max) return s;
  // Cut at last hyphen before limit (don't break mid-word)
  const cut = s.slice(0, max);
  const lastHyphen = cut.lastIndexOf('-');
  return lastHyphen > 40 ? cut.slice(0, lastHyphen) : cut;
}

Step 5: Stop-word removal (optional)

Remove "a, the, of, to, with, ..." for cleaner slugs.

const stopWords = new Set(['a', 'an', 'the', 'of', 'to', 'with', 'for', 'in', 'on']);
function removeStopWords(s: string): string {
  return s.split('-').filter(w => !stopWords.has(w)).join('-');
}

Trade-off: cleaner URL vs slight readability loss.

Step 6: Dedupe

async function uniqueSlug(base: string, table: string, scope?: any): Promise<string> {
  let slug = base;
  let n = 1;
  while (await exists(table, slug, scope)) {
    n++;
    slug = `${base}-${n}`;
  }
  return slug;
}

// Generates: 'how-to-ship', 'how-to-ship-2', 'how-to-ship-3', ...

The full function:

async function generateSlug(title: string, table: string, scope?: object): Promise<string> {
  const base = cap(kebab(asciiFold(normalize(title))));
  return uniqueSlug(base, table, scope);
}

For my code:

  • Library availability
  • I18n needs

Output:

  1. Slug function
  2. Library pick (slugify / @sindresorhus/slugify / DIY)
  3. Test cases

The library to pick: **@sindresorhus/slugify** in 2026. More comprehensive than the older `slugify`; better i18n; handles edge cases.

## Uniqueness Scope

Help me design uniqueness scope.

The decision: globally unique, or scoped?

Globally unique:

  • Slug is unique across the whole DB
  • URL: /posts/my-post (no namespace)
  • Examples: Hacker News, Reddit (per-subreddit then global)

Per-author / per-tenant:

  • Same slug allowed under different parents
  • URL: /@alice/my-post and /@bob/my-post
  • Examples: Twitter (@user/status/id), Substack, Medium

Per-collection:

  • Same slug allowed across collections
  • URL: /docs/intro and /blog/intro both valid

The right pick depends on URL design:

If URLs include a namespace (user, org, collection): scope unique within namespace. If URLs are flat: globally unique.

Database constraints:

Globally unique:

CREATE TABLE posts (
  id UUID PRIMARY KEY,
  slug VARCHAR(120) NOT NULL UNIQUE,
  ...
);

Per-author:

CREATE TABLE posts (
  id UUID PRIMARY KEY,
  author_id UUID NOT NULL,
  slug VARCHAR(120) NOT NULL,
  ...
);
CREATE UNIQUE INDEX ON posts (author_id, slug);

Reserved-word collision:

Reserve common routes; reject slugs that match.

const RESERVED = new Set([
  'api', 'admin', 'login', 'logout', 'signup', 'auth', 'about',
  'help', 'docs', 'blog', 'pricing', 'contact', 'privacy', 'terms',
  'settings', 'profile', 'account', 'billing', 'support', 'oauth',
  'static', 'public', '_next', 'static', 'assets', 'favicon.ico',
]);

function validateSlug(slug: string): boolean {
  return !RESERVED.has(slug) && /^[a-z0-9-]+$/.test(slug);
}

For per-user namespaces: similar reserved list at user-name level (/@admin, /@settings).

For my app:

  • URL design today
  • Multi-tenant model

Output:

  1. Scope decision
  2. Constraint schema
  3. Reserved-word list

The 2026 trend: **per-user / per-org namespaces** (`/@user/post`) over flat globally-unique. Cleaner mental model; fewer collisions; clearer authorship signals to search engines.

## Handling Title Edits

Help me handle title edits.

The strategy:

Option A: Slug stays the same Author edits "Ship Fast" → "Ship Faster"; slug remains ship-fast. URLs stable; SEO preserved; backlinks work.

Pros: zero URL churn Cons: slug can become stale relative to title

Option B: Slug updates; old URL redirects Author edits title; new slug ship-faster; old ship-fast 301 redirects to new.

Pros: URLs reflect current title Cons: implementation complexity; redirect chain over many edits

Option C: User explicitly picks "This will change the URL. Old URL will redirect. Continue?"

Pros: explicit; user-controlled Cons: extra friction

The recommended pattern in 2026:

Hybrid:

  • Default: Option A (slug stays stable)
  • Optional: Option B with explicit checkbox "Update URL (old URL will redirect)"

Schema:

ALTER TABLE posts ADD COLUMN slug VARCHAR(120) NOT NULL;
ALTER TABLE posts ADD COLUMN slug_changed_at TIMESTAMPTZ;

CREATE TABLE post_redirects (
  old_slug VARCHAR(120) NOT NULL,
  new_slug VARCHAR(120) NOT NULL,
  scope VARCHAR(120),  -- if scoped (e.g. user_id)
  created_at TIMESTAMPTZ DEFAULT NOW(),
  PRIMARY KEY (old_slug, scope)
);

When slug changes:

  1. INSERT into post_redirects (old_slug, new_slug, scope)
  2. UPDATE posts set slug = new_slug, slug_changed_at = NOW()
  3. Route handler: if /posts/X requested AND no post with slug X, look up redirect, 301 to new
// Next.js route handler
export async function GET(req: Request, { params }: { params: { slug: string } }) {
  const post = await db.post.findUnique({ where: { slug: params.slug } });
  if (post) return renderPost(post);
  
  const redirect = await db.postRedirect.findUnique({ where: { old_slug: params.slug } });
  if (redirect) {
    return Response.redirect(`/posts/${redirect.new_slug}`, 301);
  }
  
  return new Response('Not Found', { status: 404 });
}

Redirect chains:

If slug edited multiple times: A → B → C. Naive: /posts/A → /posts/B → /posts/C (chain). Better: when adding new redirect, update all old redirects to point to new target directly.

// On slug change A → C (where B was previous):
INSERT INTO post_redirects (old_slug, new_slug) VALUES ('B', 'C');
UPDATE post_redirects SET new_slug = 'C' WHERE new_slug = 'B';

404 protection:

Store redirects forever (or at least a year). External links may take months to update.

For my edits:

  • How often titles change
  • SEO importance

Output:

  1. Edit policy
  2. Redirect schema
  3. Route handler

The principle: **a URL once published should redirect, not 404, when its content moves**. 404 = lost SEO + lost users + lost trust. 301 = preserved + temporary cost (one extra hop). Always redirect.

## Handling Deletes (and Re-creation)

Help me handle deletion.

The cases:

Hard delete (data fully removed):

  • URL becomes 404 (or 410 Gone, more honest)
  • Slug now "available"
  • Risk: someone creates new content with same slug; old URL points to NEW content

Soft delete (data marked deleted):

  • URL might 404 or 410
  • Slug still "owned"; can't reuse
  • Restorable

Anonymized delete (PII removed; record stays):

  • URL 404 / 410
  • Slug stays "owned"

The 2026 default: soft delete + slug-locked.

Schema:

ALTER TABLE posts ADD COLUMN deleted_at TIMESTAMPTZ;
ALTER TABLE posts ADD COLUMN tombstone_until TIMESTAMPTZ;  -- Don't allow slug reuse before this

-- Unique only on non-deleted
CREATE UNIQUE INDEX ON posts (slug) WHERE deleted_at IS NULL;

Logic:

  • Delete: set deleted_at = NOW(), tombstone_until = NOW() + 1 year
  • Query: filter WHERE deleted_at IS NULL
  • Slug lookup: if no live post, redirect to "deleted" page (or 410)
  • Re-creation: when generating new slug, check tombstone — if collision, append -2

Why tombstone:

Without it: post "Ship Fast" deleted → new post "Ship Fast" → gets same slug → old URL serves new content. Confusing for users; bad for SEO.

With tombstone: 1 year minimum lockout. Old slug remains "claimed" by deleted post; new post gets ship-fast-2.

Serving deleted content:

When old slug requested:

  • Render: "This post has been deleted" (better than 404)
  • Status: 410 Gone (tells SEO to deindex)
  • Optional: link to author's other posts; or redirect to author profile

Restoration:

If post un-deleted: clear deleted_at; check no other live post took the slug.

For my deletion model: [hard / soft / anonymize]

Output:

  1. Delete strategy
  2. Tombstone duration
  3. Restoration logic

The principle: **slugs are public; they should be permanent commitments**. Delete content if you must, but don't repurpose the URL silently. Either redirect, gone-status, or hold the slug.

## Internationalization (i18n)

Help me handle i18n slugs.

The decision: transliterate or preserve?

Transliterate (default for most products):

Korean: 안녕하세요 → annyeonghaseyo Russian: Привет → privet Arabic: مرحبا → marhabaan Chinese: 你好 → ni-hao

URLs stay ASCII; readable; portable.

Pros: works everywhere; SEO-stable; easy to share Cons: loses original-language readability

Preserve (for native-speaker audience):

URL: /posts/안녕하세요 Encoded: /posts/%EC%95%88%EB%85%95%ED%95%98%EC%84%B8%EC%9A%94

Pros: native readability for native users Cons: 70% of the world sees gibberish; SEO inconsistent; copy-paste issues

Hybrid (best of both):

Slug: english-or-transliterated-version Page: native-language content URL: /posts/annyeonghaseyo or /posts/welcome

For multi-language content, use locale prefixes: /en/posts/welcome /ko/posts/welcome (same content, Korean) /ko/posts/annyeonghaseyo (separate Korean post)

Library support:

@sindresorhus/slugify handles transliteration:

import slugify from '@sindresorhus/slugify';

slugify('안녕하세요'); // 'annyeonghaseyo'
slugify('Привет мир'); // 'privet-mir'
slugify('مرحبا'); // 'mrhb'
slugify('你好世界'); // 'ni-hao-shi-jie'

Caveats:

  • CJK transliteration is lossy; multiple Korean phrases map to same English
  • Right-to-left languages need care
  • Some products preserve native; that's a valid choice

For my audience: [language profile]

Output:

  1. Strategy
  2. Library config
  3. Test cases per language

The pragmatic default: **transliterate to ASCII**. Cleaner for SEO, sharing, and bookmarking. Reserve native-character URLs for products specifically targeting one non-Latin-script audience.

## URL Design Patterns

Help me think about URL structure.

The patterns:

1. Flat: /posts/my-post /users/alice /products/widget-pro

Pros: short; easy Cons: namespace collisions; harder to scale

2. Per-tenant / per-user: /@alice/posts/my-post /teams/acme/projects/widget

Pros: clear ownership; no global collision Cons: longer URLs

3. Hierarchical: /docs/getting-started/installation /blog/2026/04/my-post

Pros: structure-aware; SEO-friendly Cons: rigid; renames cascade

4. Hybrid: Public: /@alice/my-post (per-user pretty URL) Internal: /api/posts/12345 (ID-based)

Most modern SaaS uses this — pretty for users; stable IDs for the system.

Slug + ID hybrid (canonical pattern):

URL: /posts/12345-my-post-title

  • ID: 12345 (stable, never changes)
  • Slug: my-post-title (display, can change without URL break)

If user edits title: URL updates; old URL with old slug redirects to new (because ID is stable, server can find post by ID even with stale slug).

Many CMSs and SaaS use this:

  • Stack Overflow: /questions/12345/my-question-title
  • Medium: /title-here-abc123def
  • Reddit: /r/sub/comments/abc123/title

Implementation:

// /posts/12345-my-post-title
export async function GET(req: Request, { params }: { params: { slug: string } }) {
  const match = params.slug.match(/^(\d+)-(.*)$/);
  if (!match) return new Response('Not Found', { status: 404 });
  
  const [, id, slugPart] = match;
  const post = await db.post.findUnique({ where: { id: Number(id) } });
  if (!post) return new Response('Not Found', { status: 404 });
  
  // If slug part is stale, 301 to canonical
  if (slugPart !== post.slug) {
    return Response.redirect(`/posts/${id}-${post.slug}`, 301);
  }
  
  return renderPost(post);
}

Trailing slashes:

Pick one (with or without); 301 the other.

/posts/my-post → render
/posts/my-post/ → 301 to /posts/my-post  (or vice versa)

Don't have BOTH work; SEO penalizes duplicate content.

Case sensitivity:

URLs should be case-insensitive (or case-normalized).

/Posts/My-Post → 301 to /posts/my-post

For my app: [URL pattern]

Output:

  1. URL design
  2. Slug + ID strategy
  3. Routing handlers

The pattern that wins: **slug + ID hybrid**. Best of both worlds — pretty URLs for humans + stable IDs for system. Title edits don't break URLs because the ID still resolves; old slugs redirect to current.

## Common Slug Mistakes

Help me avoid mistakes.

The 10 mistakes:

1. No collision handling Two same-titled posts → second errors out.

2. Title edits silently change slug External links break.

3. No tombstone on deletes New post takes old slug; users land on wrong content.

4. Reserved-word slugs allowed User creates "/api" or "/admin" slug; conflicts with real routes.

5. Length unbounded 500-char URLs; SEO truncates; ugly in shares.

6. Non-ASCII not handled Encoded URL gibberish.

7. Slug + ID inconsistent Sometimes /posts/abc, sometimes /posts/123-abc; users confused.

8. Trailing slash inconsistency Both /post and /post/ resolve = duplicate content.

9. Case sensitivity /Post and /post both work = duplicate content.

10. No fallback when slug not found Hard 404 instead of "did you mean..." or 301 to current.

For my code: [risks]

Output:

  1. Top 3 risks
  2. Mitigations
  3. Tests

The single most-painful slug bug: **slug change without redirect**. Author renames "Ship Fast" → "Ship Faster"; URL changes; 100K external backlinks break overnight; SEO drops 40%; recovery takes months. Always redirect.

## What Done Looks Like

A working slug strategy delivers:
- @sindresorhus/slugify (or equivalent) for generation
- Length-capped at ~80 chars
- Per-scope uniqueness (DB constraint)
- Reserved-word validation
- ID + slug hybrid for stable URLs
- Tombstones on deletion (1+ year)
- Redirect table for slug changes
- 301 from old slugs to current
- Case-insensitive routing
- Trailing-slash normalization
- I18n strategy (transliterate or hybrid)
- Test cases for emojis, accents, RTL, CJK, very long titles, reserved words

The proof you got it right: an editor renames a post; the URL updates; the old URL still works (redirects); search engines update their indexes; users with bookmarks land on the new URL. No broken backlinks; no duplicate content penalties.

## See Also

- [SEO Setup](seo-setup-chat.md) — slugs are SEO foundation
- [Programmatic SEO](programmatic-seo-chat.md) — slug patterns at scale
- [Internationalization](internationalization-chat.md) — slug i18n
- [Soft Delete vs Hard Delete](soft-delete-vs-hard-delete-chat.md) — affects slug tombstoning
- [API Versioning](api-versioning-chat.md) — versioned URLs are slugs too
- [Database Migrations](database-migrations-chat.md) — schema for slugs / redirects
- [Multi-tenancy](multi-tenancy-chat.md) — per-tenant slug scoping
- [Internal Admin Tools](internal-admin-tools-chat.md) — admin overrides slugs
- [VibeReference: SEO](https://vibereference.dev/marketing-and-seo/seo) — SEO context
- [LaunchWeek: SEO Content Audit & Refresh](https://launchweek.dev/2-content/seo-content-audit-refresh) — auditing slug churn