Designing a Flexible URL & Content Model
Website Rebuild with Next.js
Chapter 2 · Designing a Flexible URL & Content Model
Every sibling course in this series already reached the identical design independently — a real self-referencing foreign key plus a precomputed fullPath string, kept in sync: Django, Laravel, Rails, Astro (with Drizzle), and Express (which called itself, at the time, "a sixth arrival at the same design"). This chapter is that design's own origin point in the series' numbering, but — since this course's own original chapter set was lost and is being regenerated here, after all five siblings — it's honestly the seventh arrival, chronologically. A genuinely satisfying confirmation, not a coincidence: five independent teams reaching the same shape from four different ecosystems is real evidence the design itself is sound, not an artifact of one course just copying another.
Four Tree-Representation Strategies, Compared Against This Project's Own Traffic
This site's own traffic is read-heavy and write-rare: a visitor loads a chapter page far more often than an admin moves one. That single fact rules out two of the four standard options before implementation even starts.
| Strategy | Reading a full path | Moving a subtree | Verdict for this project |
|---|---|---|---|
| Adjacency List alone | Slow — walk parent links recursively on every request | Cheap — one row updated | Read cost too high on its own |
| Materialized Path alone | Fast — one indexed string column | Expensive — every descendant's path needs rewriting | Write cost acceptable, since moves are rare |
| Nested Set | Fast for subtree queries | Very expensive — most of the tree's own left/right values shift on any change | Overkill; this site never needs "all descendants" queries fast enough to justify it |
| Closure Table | Fast, but needs a second join table | Moderate — the join table needs rewriting too | More machinery than this project's own scale needs |
parentId foreign key (adjacency list — cheap writes, and the actual source of truth) with a precomputed, unique fullPath string (materialized path — cheap reads). The rare cost of a move — recalculating every descendant's own fullPath — is accepted deliberately rather than engineered away, and deferred to Chapter 10's own Admin CRUD interface, where it's actually paid.
The Self-Referencing Schema
A named relation ("PageHierarchy") is required the moment a model references itself twice — once as parent, once as children — since Prisma otherwise has no way to know the two fields describe the same relationship from opposite ends.
Migrations
prisma migrate dev diffs schema.prisma against the current migration history, writes a new SQL migration file, and applies it in one step.
Verifying onDelete: Restrict — Which Layer, Confirmed
FOREIGN KEY ("parentId") REFERENCES "Page"("id") ON DELETE RESTRICT clause — a real, database-enforced constraint, exactly like Laravel's own restrictOnDelete() and Astro's Drizzle onDelete: 'restrict'. This is a different layer from Django's own application-level PROTECT check or Rails' own dependent: :restrict_with_error callback: a raw SQL DELETE bypassing Prisma Client entirely would still be refused here, the same guarantee Express's own hand-written ON DELETE RESTRICT provides with no ORM in the way at all.
Computing fullPath Automatically — Prisma Client Extensions
prisma.$use() middleware API that could have done this same job. It's fully removed as of the version this course is built against — deprecated since Prisma 4.16.0, gone entirely as of 6.14.0/Prisma 7. Client Extensions ($extends, with a query component scoped to the page model) is the current, correct replacement, and what every call site in this course imports prisma from lib/prisma.ts to get automatically.
page.create. Moving a page (changing its parentId on an existing row) needs its own logic — recomputing that page's own fullPath and cascading the change to every descendant
Six Frameworks' ORMs (and One Without an ORM at All), Compared Honestly
| Next.js (Prisma) |
Django | Laravel | Rails | Astro (Drizzle) |
Express (raw SQL) |
|
|---|---|---|---|---|---|---|
| Delete-protection layer | Database-level (onDelete: Restrict) | Application-level (PROTECT) | Database-level (restrictOnDelete()) | Application-level (dependent: :restrict_with_error) | Database-level (onDelete: 'restrict') | Database-level (raw ON DELETE RESTRICT) |
| Lifecycle hooks? | Yes — Client Extensions | Yes — signals | Yes — model events | Yes — callbacks (before_save) | No — explicit helper functions only | No — no ORM present at all |
Hands-On Exercises
Define the self-referencing Page model in schema.prisma, including the named "PageHierarchy" relation, and run the initial migration.
📄 View solutionWrite and test computeFullPath(), confirming it correctly builds a nested path for a page with a real parent, and returns the bare slug for a page with no parent.
📄 View solutionConfirm onDelete: Restrict is a real database-level constraint by attempting a raw SQL DELETE (bypassing Prisma Client entirely) against a page that still has children, and observing the database itself refuse it.
📄 View solutionChapter 2 Quick Reference
- Adjacency list + materialized path hybrid — a real
parentIdforeign key plus a precomputed, uniquefullPathstring @relation("PageHierarchy", ...)— the named-relation requirement for a model referencing itself twiceonDelete: Restrict— a real database-level constraint, matching Laravel/Astro/Express's own layer- Prisma Client Extensions (
$extends) — the current replacement for the removed$usemiddleware; used here to auto-computefullPathon create - Deferred cost: moving a page's own subtree recalculation — not solved until Chapter 10
- Next chapter: Catch-All Routing with Next.js