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.

StrategyReading a full pathMoving a subtreeVerdict for this project
Adjacency List aloneSlow — walk parent links recursively on every requestCheap — one row updatedRead cost too high on its own
Materialized Path aloneFast — one indexed string columnExpensive — every descendant's path needs rewritingWrite cost acceptable, since moves are rare
Nested SetFast for subtree queriesVery expensive — most of the tree's own left/right values shift on any changeOverkill; this site never needs "all descendants" queries fast enough to justify it
Closure TableFast, but needs a second join tableModerate — the join table needs rewriting tooMore machinery than this project's own scale needs
The hybrid every sibling course reached the same way
Combine a real 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

// prisma/schema.prisma model Page { id Int @id @default(autoincrement()) slug String fullPath String @unique title String body String? @db.Text parentId Int? parent Page? @relation("PageHierarchy", fields: [parentId], references: [id], onDelete: Restrict) children Page[] @relation("PageHierarchy") }

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

npx prisma migrate dev --name init

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

Matches Laravel's, Astro's, and Express's layer — not Django's or Rails'
Inspecting the SQL Prisma actually generates for this migration shows a genuine 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

// lib/prisma.ts import { PrismaClient } from '@prisma/client'; const basePrisma = new PrismaClient(); async function computeFullPath(slug: string, parentId: number | null): Promise<string> { if (!parentId) return slug; const parent = await basePrisma.page.findUniqueOrThrow({ where: { id: parentId } }); return `${parent.fullPath}/${slug}`; } export const prisma = basePrisma.$extends({ query: { page: { async create({ args, query }) { args.data.fullPath = await computeFullPath(args.data.slug, args.data.parentId as number | null); return query(args); }, }, }, });
The current mechanism — not the older one
Prisma did once offer a 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.
Only covers create — a deliberate, named gap
This extension only intercepts 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 layerDatabase-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 ExtensionsYes — signalsYes — model eventsYes — callbacks (before_save)No — explicit helper functions onlyNo — no ORM present at all

Hands-On Exercises

Exercise 1

Define the self-referencing Page model in schema.prisma, including the named "PageHierarchy" relation, and run the initial migration.

📄 View solution
Exercise 2

Write 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 solution
Exercise 3

Confirm 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 solution

Chapter 2 Quick Reference

  • Adjacency list + materialized path hybrid — a real parentId foreign key plus a precomputed, unique fullPath string
  • @relation("PageHierarchy", ...) — the named-relation requirement for a model referencing itself twice
  • onDelete: Restrict — a real database-level constraint, matching Laravel/Astro/Express's own layer
  • Prisma Client Extensions ($extends) — the current replacement for the removed $use middleware; used here to auto-compute fullPath on create
  • Deferred cost: moving a page's own subtree recalculation — not solved until Chapter 10
  • Next chapter: Catch-All Routing with Next.js