Skip to content

🔗 URL strategy: vanity slugs vs opaque IDs (and the rename gap) #86

Description

@teetangh

Context

We're deciding whether public URLs should use human-readable vanity slugs (/courses/demo-academy/system-design-interview) or opaque IDs (/courses/clx7k2j9800abc). The concern raised was that collision-free slug generation is real engineering overhead — a tinyurl-style generator, uniqueness handling, rename logic — and that IDs would sidestep all of it.

Auditing the schema first changes the question. The expensive problem largely doesn't exist here, because slugs are already namespaced.

What we actually have today

Every slug constraint in prisma/models/*.prisma:

Model Constraint Scope
Course @@unique([tenantId, slug]) per tenant
Project @@unique([tenantId, slug]) per tenant
DigitalProduct @@unique([tenantId, slug]) per tenant
Cohort @@unique([courseId, slug]) per course
Tenant slug String @unique global — namespace root
PortfolioProfile slug String @unique global — public handle
Category, Bundle slug String @unique global, platform-managed

Because course and project slugs are unique per tenant, two creators can both own system-design-interview:

  • /courses/demo-academy/system-design-interview
  • /courses/pixelforge/system-design-interview

No global collision space, therefore no collision-free generator is needed. The required logic is slugify(title) plus a -2 suffix on the rare within-tenant clash.

The only globally-unique slugs are the two that should be — the tenant handle and the portfolio handle. For those, "that name is taken, choose another" is ordinary signup UX, the same as picking a username.

We are already running the hybrid pattern

Public, indexable, shareable surfaces use slugs. The authenticated app uses opaque cuids:

/courses/[tenantSlug]/[courseSlug]        ← public
/projects/[tenantSlug]/[projectSlug]      ← public
/c/[tenantSlug]                           ← public storefront
/p/[slug]                                 ← public portfolio
/verify/[code]                            ← public credential

/learn/courses/[courseId]                 ← in-app, cuid
/learn/projects/[instanceId]              ← in-app, cuid
/studio/[tenantSlug]/courses/[courseId]   ← in-app, cuid
/mentor/instances/[instanceId]            ← in-app, cuid

This split is correct and needs no change.

Both sides, stated fairly

For opaque IDs

  • No collision logic at all.
  • Renames are free — the URL never changes, so there is nothing to redirect.
  • No reserved-path conflicts (/courses/new, /courses/admin).
  • No unicode, casing, or profanity handling on creator-supplied text.

For vanity slugs

  • A URL a human can read before clicking. This matters most on precisely the pages the business depends on: a credential pasted into a hiring conversation, a course link shared in a WhatsApp group.
  • Survives being spoken aloud, printed on a slide, or read off a screen.
  • Carries organisational context — demo-academy in the path tells a reader who issued the thing.

Recommendation

Keep slugs on public pages, keep IDs in the app, and build nothing new right now.

  1. Do not change course/project slugs. Per-tenant scoping already removed the hard problem. Migrating to IDs would trade a solved problem for a worse public surface.
  2. The real gap is renames, not collisions. Today, renaming a course changes its slug and the old URL 404s — breaking any link already shared. This is the only part genuinely worth engineering.
  3. Optional future hedge — the Stack Overflow pattern. Resolve by ID and treat the slug as decoration (/questions/11227809/why-is-processing-a-sorted-array-faster serves fine with the text portion altered). That makes renames unbreakable and collisions impossible by construction, without giving up readability. Not needed yet.

Proposed implementation — WITHDRAWN

Superseded by the research comment below. This section proposed a slug_history
table and 301 redirects to fix renames. That gap does not exist: updateCourse and
updateProject never write slug, so renaming a course cannot break its URL. The slug is
set once at creation and is independent of the title thereafter — the same decoupling
Thinkific ships deliberately as its documented default.

No migration, no schema-approved label, no work required. Left visible rather than
deleted, so the reasoning trail stays honest.

Not doing

  • Migrating public URLs to opaque IDs.
  • Building any collision-free slug generator or short-id service.
  • Changing the in-app cuid routes.

Open question

Whether creators should be able to edit a slug independently of the title, or whether it should always be derived. Deriving is simpler and keeps title and URL honest; manual editing is better for SEO and for fixing a bad auto-slug. Leaning derived-by-default with an advanced override, but this is a product call.


Market research is complete — see the comment below. It confirms the tenant-namespacing recommendation and contradicts the rename section, which has been withdrawn above.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestmarketingMarketing surfacestenancyMulti-tenant storefronts

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions