FeaturesNested RoutingDesign

Nested Routing Design

Status: Design Supersedes: Dynamic Sections (flat routing with content_type on sections) Goal: Arbitrary-depth content hierarchy with filesystem semantics, movable content, and automatic redirects.


Problem

The current routing system is flat. Sections map 1:1 to top-level URL segments. A section like projects can only contain content items at /projects/{slug} — there is no way to nest sections inside sections, and no way to create deep organizational structures like /creative-work/photography/portraits/some-photo-essay.

Content types are encoded into the section definition via content_type, which forces every section to contain exactly one kind of content. A section cannot hold both blog posts and projects, or present a mixed broadsheet of child content.

Core Principles

Sections are folders. Every section can contain both child sections and content items. There is no distinction between a “folder section” and a “leaf section.” A section at any depth can hold children of any kind.

Content types live on content items, not sections. A story knows it’s a story. A photo essay knows it’s a photo essay. The section it lives in doesn’t need to know or care. This means a single section can contain stories, projects, film reviews, and podcasts side by side.

Display types live on sections. A section decides how to render its children: card grid, infinite scroll, gallery, broadsheet. The display type operates on a uniform listing interface that all content types implement. A card grid of mixed stories and projects renders identically at the list level.

Filesystem semantics for URLs. The full URL of a content item is {section.path}/{item.slug}. Moving content or renaming a section changes URLs. The system writes 301 redirects automatically so old URLs continue to work.

Subtree scoping. A section can only display content from its own subtree. The root section (/) can display anything. A section at /creative-work/ can display anything under creative-work, but not content under /about/. This is enforced at the query level, not by convention.


Design Decisions

ConceptDecisionReasoning
Hierarchy depthUnlimited nesting via parent_id + materialized pathFilesystem model requires arbitrary depth
Content type on sectionsRemovedSections render any content type; type lives on the item
Slug uniquenessUnique within parent, not globallyMirrors filesystem — a/draft and b/draft can coexist
Path resolutionSingle backend endpoint resolves full pathFrontend stays simple — one HTTP call regardless of depth
Content movesUpdate section_id + write redirectOld URLs return 301 to new location
Section movesCascade path updates + bulk redirectsAll descendants and their content get new paths
Display typeSection-level onlyDetermines how children render in list view
Listing interfaceUniform shape across all content typesDisplay components don’t need to know content types
Broadsheet displayPulls flagged items from subtreeRoot broadsheet can show anything; nested broadsheets scoped to subtree

Architecture Overview

URL: /creative-work/photography/portraits/beach-sunset

Frontend catch-all [...slugPath]
        |
        v
GET /sections/resolve-path/creative-work/photography/portraits/beach-sunset
        |
        v
Backend walks the path:
  1. "creative-work" -> section (exists)
  2. "photography"   -> child section of creative-work (exists)
  3. "portraits"     -> child section of photography (exists)
  4. "beach-sunset"  -> not a section -> treat as content item in "portraits"
        |
        v
Returns: { section: portraits, content_item: beach-sunset, breadcrumbs: [...] }
        |
        v
Frontend renders:
  - If content_item: detail view based on content_item.content_type
  - If section only: list view using section.display_type

What Changes from Dynamic Sections

AspectDynamic Sections (current)Nested Routing (new)
URL depth2 levels max (/section/item)Unlimited
Section nestingparent_id exists but unused in routingFully resolved in routing
content_type on sectionRequired, determines what API to callRemoved
Content in a sectionAll same typeMixed types allowed
Path resolutionFrontend splits URL into 1-2 segmentsBackend resolves full path
Content movesNot supportedSupported with 301 redirects
Display typesTied to content typeIndependent of content type
List renderingType-specific list componentsUniform ListingItem interface