Practitionist / Practitionist/elluminar_web
🔗 URL strategy: vanity slugs vs opaque IDs (and the rename gap)
@teetangh is already working on this.
Since Aug 29, 2026.
- Dominant language
- TypeScript
- Stars
- 1
- Forks
- 0
- Avg merge
- 17h 2m
- Merged PRs (30d)
- 18
Description
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-academyin 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.
- 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.
- 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.
- Optional future hedge — the Stack Overflow pattern. Resolve by ID and treat the slug as decoration (
/questions/11227809/why-is-processing-a-sorted-array-fasterserves 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:updateCourseand
updateProjectnever writeslug, 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-approvedlabel, 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.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.