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.
- 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-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.
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:Course@@unique([tenantId, slug])Project@@unique([tenantId, slug])DigitalProduct@@unique([tenantId, slug])Cohort@@unique([courseId, slug])Tenantslug String @uniquePortfolioProfileslug String @uniqueCategory,Bundleslug String @uniqueBecause 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-interviewNo global collision space, therefore no collision-free generator is needed. The required logic is
slugify(title)plus a-2suffix 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:
This split is correct and needs no change.
Both sides, stated fairly
For opaque IDs
/courses/new,/courses/admin).For vanity slugs
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.
/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— WITHDRAWNNot doing
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.