Nested Routing Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Replace flat section routing with arbitrary-depth nested sections, mixed content types per section, movable content with automatic 301 redirects, and a unified listing interface.
Architecture: Sections gain a materialized path field and lose content_type. A single backend endpoint (/sections/resolve-path/{path}) resolves any URL depth. Content items conform to a ListingItem interface for display-type-agnostic rendering. Move operations write redirects and cascade path updates through the subtree.
Tech Stack: Next.js (Pages Router), FastAPI, MongoDB (Motor), TypeScript, Playwright, Vitest, pytest
Design Doc: Nested Routing
Prerequisite: Asset Reorganization PR must be merged first.
Phase 1: Data Model & Indexes
Task 1.1: Section Model — Add path, Remove content_type
Files:
- Modify:
backend/models/section.py - Test:
backend/tests/test_sections_api.py
Step 1: Write failing tests for new model shape
Add to backend/tests/test_sections_api.py:
class TestSectionPathField:
@pytest.mark.asyncio
async def test_create_section_generates_path(self, sections_async_client, mock_auth):
"""Creating a top-level section sets path = slug."""
response = await sections_async_client.post(
"/sections",
json={"title": "Blog", "display_type": "feed", "nav_visibility": "main"},
headers={"Authorization": "Bearer mock-token"},
)
assert response.status_code == 201
data = response.json()
assert data["path"] == data["slug"]
@pytest.mark.asyncio
async def test_create_child_section_generates_nested_path(self, sections_async_client, mock_auth):
"""Creating a child section sets path = parent.path/child.slug."""
# Create parent
parent_resp = await sections_async_client.post(
"/sections",
json={"title": "Creative Work", "display_type": "card-grid", "nav_visibility": "main"},
headers={"Authorization": "Bearer mock-token"},
)
parent_id = parent_resp.json()["id"]
# Create child
child_resp = await sections_async_client.post(
"/sections",
json={
"title": "Photography",
"display_type": "gallery",
"nav_visibility": "hidden",
"parent_id": parent_id,
},
headers={"Authorization": "Bearer mock-token"},
)
child = child_resp.json()
assert child["path"] == f"creative-work/photography"
@pytest.mark.asyncio
async def test_section_response_has_no_content_type(self, sections_async_client, mock_auth):
"""Section responses should not include content_type."""
response = await sections_async_client.post(
"/sections",
json={"title": "Test", "display_type": "feed", "nav_visibility": "main"},
headers={"Authorization": "Bearer mock-token"},
)
data = response.json()
assert "content_type" not in dataStep 2: Run tests to verify they fail
Run: make test
Expected: FAIL — path not in response, content_type still present
Step 3: Update section model
Modify backend/models/section.py:
- Add
path: strfield toSectionResponse(computed, not user-provided) - Remove
content_typefromSectionCreate,SectionUpdate,SectionResponse - Keep
display_typeon all models
Modify backend/handlers/sections.py:
- In
create_section: after slug generation, computepath:- If
parent_idis None:path = slug - If
parent_idexists: fetch parent,path = f"{parent.path}/{slug}"
- If
- Store
pathin the document - In
update_section: if slug changes, recomputepathand cascade to children (Task 1.4)
Step 4: Run tests
Run: make test
Expected: New tests PASS. Some existing tests may fail due to content_type removal — update them.
Step 5: Commit
git add backend/models/section.py backend/handlers/sections.py backend/tests/test_sections_api.py
git commit -m "feat: add path field to sections, remove content_type"Task 1.2: Update Database Indexes
Files:
- Modify:
backend/database.py - Test: Verify via migration
Step 1: Update index definitions
In backend/database.py, update the sections collection indexes:
# Replace global slug uniqueness with scoped uniqueness
# Old: {"slug": 1} unique
# New: {"parent_id": 1, "slug": 1} unique compound
# Add: {"path": 1} unique
sections = db["sections"]
await sections.create_index([("path", 1)], unique=True)
await sections.create_index([("parent_id", 1), ("slug", 1)], unique=True)
# Keep existing: nav_visibility + sort_order, parent_id + sort_orderAdd content collection indexes for scoped slug uniqueness:
for coll_name in ["stories", "projects", "pages", "photo_essays"]:
coll = db[coll_name]
await coll.create_index([("section_id", 1), ("slug", 1)], unique=True, sparse=True)Step 2: Write migration for index changes
Create backend/migrations/0016_nested_routing_indexes.py:
- Drop old global
slugunique index on sections - Create new compound
(parent_id, slug)unique index - Create
pathunique index - Create
(section_id, slug)indexes on content collections - Populate
pathfield on all existing sections (all top-level, sopath = slug)
Step 3: Test migration locally
Run: make dev-local && make migrate
Verify: make test passes
Step 4: Commit
git add backend/database.py backend/migrations/0016_nested_routing_indexes.py
git commit -m "feat: update indexes for nested routing — scoped slug uniqueness, path index"Task 1.3: Redirects Collection
Files:
- Create:
backend/models/redirect.py - Modify:
backend/database.py - Test:
backend/tests/test_redirects.py
Step 1: Write failing tests
Create backend/tests/test_redirects.py:
import pytest
from models.redirect import RedirectCreate, RedirectResponse
class TestRedirectModel:
def test_redirect_create_valid(self):
r = RedirectCreate(
old_path="blog/my-post",
new_path="archive/my-post",
)
assert r.old_path == "blog/my-post"
def test_redirect_create_with_content(self):
r = RedirectCreate(
old_path="blog/my-post",
new_path="archive/my-post",
content_id="abc123",
content_type="story",
)
assert r.content_id == "abc123"
def test_redirect_create_strips_leading_slash(self):
r = RedirectCreate(
old_path="/blog/my-post",
new_path="/archive/my-post",
)
assert r.old_path == "blog/my-post"
assert r.new_path == "archive/my-post"Step 2: Run tests — fail
Run: make test
Expected: FAIL — module does not exist
Step 3: Implement redirect model
Create backend/models/redirect.py:
from datetime import datetime, timezone
from typing import Optional
from pydantic import BaseModel, field_validator
class RedirectCreate(BaseModel):
old_path: str
new_path: str
content_id: Optional[str] = None
content_type: Optional[str] = None
expires_at: Optional[datetime] = None
@field_validator("old_path", "new_path", mode="before")
@classmethod
def strip_leading_slash(cls, v: str) -> str:
return v.lstrip("/")
class RedirectResponse(BaseModel):
id: str
old_path: str
new_path: str
content_id: Optional[str] = None
content_type: Optional[str] = None
created_at: datetime
expires_at: Optional[datetime] = NoneAdd to backend/database.py:
async def get_redirects_collection():
db = await get_database()
return db["redirects"]Add redirect index in ensure_indexes:
redirects = db["redirects"]
await redirects.create_index([("old_path", 1)], unique=True)Step 4: Run tests — pass
Run: make test
Expected: PASS
Step 5: Commit
git add backend/models/redirect.py backend/database.py backend/tests/test_redirects.py
git commit -m "feat: add redirects collection and model"Task 1.4: Path Cascade on Section Update
Files:
- Modify:
backend/handlers/sections.py - Test:
backend/tests/test_sections_api.py
Step 1: Write failing tests for path cascade
class TestPathCascade:
@pytest.mark.asyncio
async def test_rename_section_updates_child_paths(self, sections_async_client, mock_auth):
"""Renaming a parent section cascades path changes to children."""
# Create parent
parent = (await sections_async_client.post(
"/sections",
json={"title": "Old Name", "display_type": "feed", "nav_visibility": "main"},
headers={"Authorization": "Bearer mock-token"},
)).json()
# Create child
child = (await sections_async_client.post(
"/sections",
json={"title": "Child", "display_type": "feed", "nav_visibility": "hidden", "parent_id": parent["id"]},
headers={"Authorization": "Bearer mock-token"},
)).json()
assert child["path"] == "old-name/child"
# Rename parent
await sections_async_client.put(
f"/sections/{parent['id']}",
json={"title": "New Name"},
headers={"Authorization": "Bearer mock-token"},
)
# Fetch child — path should be updated
updated_child = (await sections_async_client.get(f"/sections/{child['id']}")).json()
assert updated_child["path"] == "new-name/child"
@pytest.mark.asyncio
async def test_rename_cascades_to_grandchildren(self, sections_async_client, mock_auth):
"""Path cascade goes through multiple levels."""
root = (await sections_async_client.post(
"/sections",
json={"title": "Root", "display_type": "feed", "nav_visibility": "main"},
headers={"Authorization": "Bearer mock-token"},
)).json()
mid = (await sections_async_client.post(
"/sections",
json={"title": "Mid", "display_type": "feed", "nav_visibility": "hidden", "parent_id": root["id"]},
headers={"Authorization": "Bearer mock-token"},
)).json()
leaf = (await sections_async_client.post(
"/sections",
json={"title": "Leaf", "display_type": "feed", "nav_visibility": "hidden", "parent_id": mid["id"]},
headers={"Authorization": "Bearer mock-token"},
)).json()
assert leaf["path"] == "root/mid/leaf"
# Rename root
await sections_async_client.put(
f"/sections/{root['id']}",
json={"title": "Base"},
headers={"Authorization": "Bearer mock-token"},
)
updated_leaf = (await sections_async_client.get(f"/sections/{leaf['id']}")).json()
assert updated_leaf["path"] == "base/mid/leaf"Step 2: Run tests — fail
Run: make test
Expected: FAIL — child paths not updated on rename
Step 3: Implement cascade
Add helper to backend/handlers/sections.py:
async def cascade_path_updates(
collection: AsyncIOMotorCollection,
section_id: str,
new_path: str,
):
"""Recursively update paths of all descendant sections."""
children = await collection.find(
{"parent_id": section_id, "deleted": {"$ne": True}}
).to_list(None)
for child in children:
child_new_path = f"{new_path}/{child['slug']}"
await collection.update_one(
{"_id": child["_id"]},
{"$set": {"path": child_new_path, "updatedDate": datetime.now(timezone.utc)}},
)
await cascade_path_updates(collection, str(child["_id"]), child_new_path)Wire into update_section: after slug change and path recomputation, call cascade_path_updates.
Step 4: Run tests — pass
Run: make test
Expected: PASS
Step 5: Commit
git add backend/handlers/sections.py backend/tests/test_sections_api.py
git commit -m "feat: cascade path updates to descendants on section rename"Phase 2: Path Resolution Endpoint
Task 2.1: resolve-path Endpoint — Section Resolution
Files:
- Create:
backend/handlers/path_resolution.py - Modify:
backend/app.py(register router) - Test:
backend/tests/test_path_resolution.py
Step 1: Write failing tests
Create backend/tests/test_path_resolution.py:
import pytest
from bson import ObjectId
class TestResolvePathSection:
"""Test path resolution returning section responses."""
@pytest.mark.asyncio
async def test_resolve_top_level_section(self, async_client, mock_auth):
"""Top-level path resolves to a section."""
# Seed a section with path="blog"
db = async_client.app.state.db # or however the test DB is accessed
section_id = ObjectId()
await db["sections"].insert_one({
"_id": section_id,
"slug": "blog",
"title": "Blog",
"path": "blog",
"parent_id": None,
"display_type": "feed",
"nav_visibility": "main",
"is_published": True,
"sort_order": 0,
"deleted": False,
})
response = await async_client.get("/sections/resolve-path/blog")
assert response.status_code == 200
data = response.json()
assert data["type"] == "section"
assert data["section"]["path"] == "blog"
assert data["section"]["title"] == "Blog"
assert len(data["breadcrumbs"]) == 1
assert data["breadcrumbs"][0]["title"] == "Blog"
@pytest.mark.asyncio
async def test_resolve_nested_section(self, async_client, mock_auth):
"""Nested path resolves through the section hierarchy."""
parent_id = ObjectId()
child_id = ObjectId()
await db["sections"].insert_many([
{
"_id": parent_id, "slug": "creative", "title": "Creative",
"path": "creative", "parent_id": None, "display_type": "card-grid",
"nav_visibility": "main", "is_published": True, "sort_order": 0,
},
{
"_id": child_id, "slug": "photos", "title": "Photos",
"path": "creative/photos", "parent_id": str(parent_id),
"display_type": "gallery", "nav_visibility": "hidden",
"is_published": True, "sort_order": 0,
},
])
response = await async_client.get("/sections/resolve-path/creative/photos")
assert response.status_code == 200
data = response.json()
assert data["type"] == "section"
assert data["section"]["path"] == "creative/photos"
assert len(data["breadcrumbs"]) == 2
assert data["breadcrumbs"][0]["title"] == "Creative"
assert data["breadcrumbs"][1]["title"] == "Photos"
@pytest.mark.asyncio
async def test_resolve_nonexistent_path_returns_404(self, async_client):
response = await async_client.get("/sections/resolve-path/does-not-exist")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_resolve_unpublished_section_returns_404(self, async_client):
await db["sections"].insert_one({
"_id": ObjectId(), "slug": "hidden", "title": "Hidden",
"path": "hidden", "parent_id": None, "display_type": "feed",
"nav_visibility": "hidden", "is_published": False, "sort_order": 0,
})
response = await async_client.get("/sections/resolve-path/hidden")
assert response.status_code == 404Step 2: Run tests — fail
Run: make test
Expected: FAIL — endpoint does not exist
Step 3: Implement resolve-path for sections
Create backend/handlers/path_resolution.py:
from bson import ObjectId
from database import get_db
from fastapi import APIRouter, HTTPException, Request
router = APIRouter()
async def build_breadcrumbs(db, section: dict) -> list:
"""Walk ancestors to build breadcrumb chain."""
crumbs = []
current = section
while current:
crumbs.append({"title": current["title"], "path": current["path"]})
if current.get("parent_id"):
current = await db["sections"].find_one({
"_id": ObjectId(current["parent_id"]),
"deleted": {"$ne": True},
})
else:
current = None
crumbs.reverse()
return crumbs
@router.get("/sections/resolve-path/{full_path:path}")
async def resolve_path(request: Request, full_path: str):
"""Resolve a URL path to a section or content item."""
db = await get_db()
path = full_path.strip("/")
# Try full path as a section
section = await db["sections"].find_one({
"path": path,
"is_published": True,
"deleted": {"$ne": True},
})
if section:
section["id"] = str(section.pop("_id"))
breadcrumbs = await build_breadcrumbs(db, {**section, "_id": ObjectId(section["id"])})
return {
"type": "section",
"section": section,
"breadcrumbs": breadcrumbs,
}
# TODO: Task 2.2 — content item resolution
# TODO: Task 4.1 — redirect resolution
raise HTTPException(status_code=404, detail="Path not found")Register in backend/app.py:
from handlers.path_resolution import router as path_resolution_router
app.include_router(path_resolution_router)Step 4: Run tests — pass
Run: make test
Expected: PASS
Step 5: Commit
git add backend/handlers/path_resolution.py backend/app.py backend/tests/test_path_resolution.py
git commit -m "feat: resolve-path endpoint — section resolution with breadcrumbs"Task 2.2: resolve-path — Content Item Resolution
Files:
- Modify:
backend/handlers/path_resolution.py - Test:
backend/tests/test_path_resolution.py
Step 1: Write failing tests
Add to backend/tests/test_path_resolution.py:
class TestResolvePathContent:
"""Test path resolution returning content item responses."""
@pytest.mark.asyncio
async def test_resolve_content_item(self, async_client, mock_auth):
"""Last segment resolves as content within parent section."""
section_id = ObjectId()
await db["sections"].insert_one({
"_id": section_id, "slug": "blog", "title": "Blog",
"path": "blog", "parent_id": None, "display_type": "feed",
"is_published": True, "sort_order": 0,
})
story_id = ObjectId()
await db["stories"].insert_one({
"_id": story_id, "slug": "my-post", "title": "My Post",
"section_id": str(section_id), "is_published": True,
"summary": "A post", "content": "<p>Hello</p>",
"deleted": False,
})
response = await async_client.get("/sections/resolve-path/blog/my-post")
assert response.status_code == 200
data = response.json()
assert data["type"] == "content"
assert data["content_item"]["slug"] == "my-post"
assert data["content_item"]["content_type"] == "story"
assert data["section"]["path"] == "blog"
assert len(data["breadcrumbs"]) == 2
@pytest.mark.asyncio
async def test_resolve_nested_content_item(self, async_client, mock_auth):
"""Content item under a nested section resolves correctly."""
parent_id = ObjectId()
child_id = ObjectId()
await db["sections"].insert_many([
{"_id": parent_id, "slug": "creative", "title": "Creative",
"path": "creative", "parent_id": None, "display_type": "card-grid",
"is_published": True, "sort_order": 0},
{"_id": child_id, "slug": "photos", "title": "Photos",
"path": "creative/photos", "parent_id": str(parent_id),
"display_type": "gallery", "is_published": True, "sort_order": 0},
])
await db["photo_essays"].insert_one({
"_id": ObjectId(), "slug": "beach-sunset", "title": "Beach Sunset",
"section_id": str(child_id), "is_published": True,
})
response = await async_client.get("/sections/resolve-path/creative/photos/beach-sunset")
assert response.status_code == 200
data = response.json()
assert data["type"] == "content"
assert data["content_item"]["content_type"] == "photo_essay"
@pytest.mark.asyncio
async def test_content_not_found_returns_404(self, async_client):
"""Non-existent content item under a valid section returns 404."""
section_id = ObjectId()
await db["sections"].insert_one({
"_id": section_id, "slug": "blog", "title": "Blog",
"path": "blog", "parent_id": None, "display_type": "feed",
"is_published": True, "sort_order": 0,
})
response = await async_client.get("/sections/resolve-path/blog/nonexistent")
assert response.status_code == 404Step 2: Run tests — fail
Run: make test
Expected: FAIL — content resolution not implemented
Step 3: Implement content resolution
Add to backend/handlers/path_resolution.py:
CONTENT_COLLECTIONS = [
("stories", "story"),
("projects", "project"),
("photo_essays", "photo_essay"),
("pages", "page"),
]
async def find_content_in_section(db, section_id: str, slug: str) -> dict | None:
"""Search all content collections for an item with this slug in this section."""
for collection_name, content_type in CONTENT_COLLECTIONS:
item = await db[collection_name].find_one({
"section_id": section_id,
"slug": slug,
"is_published": True,
"deleted": {"$ne": True},
})
if item:
item["id"] = str(item.pop("_id"))
item["content_type"] = content_type
return item
return NoneUpdate the resolve_path handler — after the section-only check fails, split off the last segment:
# Split: try parent path as section, last segment as content
segments = path.split("/")
if len(segments) > 1:
parent_path = "/".join(segments[:-1])
item_slug = segments[-1]
section = await db["sections"].find_one({
"path": parent_path,
"is_published": True,
"deleted": {"$ne": True},
})
if section:
content = await find_content_in_section(db, str(section["_id"]), item_slug)
if content:
section["id"] = str(section.pop("_id"))
breadcrumbs = await build_breadcrumbs(db, {**section, "_id": ObjectId(section["id"])})
breadcrumbs.append({"title": content["title"], "path": path})
return {
"type": "content",
"section": section,
"content_item": content,
"breadcrumbs": breadcrumbs,
}Step 4: Run tests — pass
Run: make test
Expected: PASS
Step 5: Commit
git add backend/handlers/path_resolution.py backend/tests/test_path_resolution.py
git commit -m "feat: resolve-path content item resolution across collections"Phase 3: Children Endpoint & ListingItem
Task 3.1: ListingItem Response Model
Files:
- Create:
backend/models/listing.py - Test:
backend/tests/test_listing_model.py
Step 1: Write failing tests
from models.listing import ListingItem
class TestListingItem:
def test_story_to_listing_item(self):
item = ListingItem(
id="abc",
slug="my-post",
item_type="content",
content_type="story",
title="My Post",
summary="A summary",
image_url=None,
video_url=None,
tags=["python"],
is_published=True,
is_featured=False,
created_at="2026-03-29T00:00:00Z",
updated_at="2026-03-29T00:00:00Z",
)
assert item.content_type == "story"
def test_section_to_listing_item(self):
item = ListingItem(
id="def",
slug="photos",
item_type="section",
content_type=None,
title="Photos",
path="creative/photos",
display_type="gallery",
sort_order=0,
)
assert item.item_type == "section"
assert item.content_type is NoneStep 2: Run — fail. Step 3: Implement model. Step 4: Run — pass.
Create backend/models/listing.py:
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class ListingItem(BaseModel):
id: str
slug: str
item_type: str # "section" or "content"
content_type: Optional[str] = None
title: str
summary: Optional[str] = None
image_url: Optional[str] = None
video_url: Optional[str] = None
path: Optional[str] = None
display_type: Optional[str] = None
tags: list[str] = []
is_published: bool = True
is_featured: bool = False
sort_order: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
user_id: Optional[str] = NoneStep 5: Commit
git add backend/models/listing.py backend/tests/test_listing_model.py
git commit -m "feat: ListingItem model for unified section children responses"Task 3.2: Children Endpoint
Files:
- Create:
backend/handlers/children.py(or add topath_resolution.py) - Modify:
backend/app.py - Test:
backend/tests/test_children_endpoint.py
Step 1: Write failing tests
Create backend/tests/test_children_endpoint.py:
class TestChildrenEndpoint:
@pytest.mark.asyncio
async def test_returns_child_sections_and_content(self, async_client, mock_auth):
"""Endpoint returns both child sections and content items."""
parent_id = ObjectId()
child_section_id = ObjectId()
await db["sections"].insert_many([
{"_id": parent_id, "slug": "blog", "title": "Blog", "path": "blog",
"parent_id": None, "display_type": "feed", "is_published": True, "sort_order": 0},
{"_id": child_section_id, "slug": "tech", "title": "Tech", "path": "blog/tech",
"parent_id": str(parent_id), "display_type": "feed", "is_published": True, "sort_order": 0},
])
await db["stories"].insert_one({
"_id": ObjectId(), "slug": "my-post", "title": "My Post",
"section_id": str(parent_id), "is_published": True,
"summary": "A post", "tags": [], "createdDate": datetime.now(timezone.utc),
})
response = await async_client.get(f"/sections/{parent_id}/children")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 2
types = [item["item_type"] for item in data["items"]]
assert "section" in types
assert "content" in types
# Sections come first
section_indices = [i for i, t in enumerate(types) if t == "section"]
content_indices = [i for i, t in enumerate(types) if t == "content"]
assert max(section_indices) < min(content_indices)
@pytest.mark.asyncio
async def test_mixed_content_types(self, async_client, mock_auth):
"""Section with stories and projects returns both."""
section_id = ObjectId()
await db["sections"].insert_one({
"_id": section_id, "slug": "work", "title": "Work", "path": "work",
"parent_id": None, "display_type": "card-grid", "is_published": True, "sort_order": 0,
})
await db["stories"].insert_one({
"_id": ObjectId(), "slug": "story-1", "title": "A Story",
"section_id": str(section_id), "is_published": True, "tags": [],
"createdDate": datetime.now(timezone.utc),
})
await db["projects"].insert_one({
"_id": ObjectId(), "slug": "project-1", "title": "A Project",
"section_id": str(section_id), "is_published": True, "tags": [],
"technologies": [], "createdDate": datetime.now(timezone.utc),
})
response = await async_client.get(f"/sections/{section_id}/children")
data = response.json()
content_types = [i["content_type"] for i in data["items"] if i["item_type"] == "content"]
assert "story" in content_types
assert "project" in content_types
@pytest.mark.asyncio
async def test_subtree_featured(self, async_client, mock_auth):
"""subtree=true&featured_only=true returns featured items from descendants."""
root_id = ObjectId()
child_id = ObjectId()
await db["sections"].insert_many([
{"_id": root_id, "slug": "root", "title": "Root", "path": "root",
"parent_id": None, "display_type": "feed", "is_published": True, "sort_order": 0},
{"_id": child_id, "slug": "child", "title": "Child", "path": "root/child",
"parent_id": str(root_id), "display_type": "feed", "is_published": True, "sort_order": 0},
])
await db["stories"].insert_one({
"_id": ObjectId(), "slug": "featured", "title": "Featured Post",
"section_id": str(child_id), "is_published": True, "is_featured": True,
"tags": [], "createdDate": datetime.now(timezone.utc),
})
await db["stories"].insert_one({
"_id": ObjectId(), "slug": "normal", "title": "Normal Post",
"section_id": str(child_id), "is_published": True, "is_featured": False,
"tags": [], "createdDate": datetime.now(timezone.utc),
})
response = await async_client.get(
f"/sections/{root_id}/children?subtree=true&featured_only=true"
)
data = response.json()
assert all(i.get("is_featured", False) for i in data["items"] if i["item_type"] == "content")
titles = [i["title"] for i in data["items"]]
assert "Featured Post" in titles
assert "Normal Post" not in titles
@pytest.mark.asyncio
async def test_pagination(self, async_client, mock_auth):
section_id = ObjectId()
await db["sections"].insert_one({
"_id": section_id, "slug": "blog", "title": "Blog", "path": "blog",
"parent_id": None, "display_type": "feed", "is_published": True, "sort_order": 0,
})
for i in range(5):
await db["stories"].insert_one({
"_id": ObjectId(), "slug": f"post-{i}", "title": f"Post {i}",
"section_id": str(section_id), "is_published": True,
"tags": [], "createdDate": datetime.now(timezone.utc),
})
response = await async_client.get(f"/sections/{section_id}/children?limit=2&offset=0")
data = response.json()
assert len(data["items"]) == 2
assert data["total"] == 5Step 2: Run — fail. Step 3: Implement. Step 4: Run — pass.
The handler queries child sections + all content collections for section_id, merges, sorts (sections first by sort_order, content by created_at desc), and paginates.
For subtree=true: collect all descendant section IDs recursively, then query content where section_id is in that set.
Step 5: Commit
git add backend/handlers/children.py backend/app.py backend/tests/test_children_endpoint.py
git commit -m "feat: children endpoint — mixed content types, subtree queries, pagination"Phase 4: Move & Redirect Operations
Task 4.1: Content Move Endpoint
Files:
- Create:
backend/handlers/moves.py - Modify:
backend/app.py - Test:
backend/tests/test_moves.py
Step 1: Write failing tests
class TestContentMove:
@pytest.mark.asyncio
async def test_move_story_to_new_section(self, async_client, mock_auth):
"""Moving a story updates section_id and creates a redirect."""
section_a = ObjectId()
section_b = ObjectId()
await db["sections"].insert_many([
{"_id": section_a, "slug": "blog", "path": "blog", "parent_id": None,
"display_type": "feed", "is_published": True, "sort_order": 0, "title": "Blog"},
{"_id": section_b, "slug": "archive", "path": "archive", "parent_id": None,
"display_type": "feed", "is_published": True, "sort_order": 0, "title": "Archive"},
])
story_id = ObjectId()
await db["stories"].insert_one({
"_id": story_id, "slug": "my-post", "title": "My Post",
"section_id": str(section_a), "is_published": True,
})
response = await async_client.put(
f"/content/story/{story_id}/move",
json={"target_section_id": str(section_b)},
headers={"Authorization": "Bearer mock-token"},
)
assert response.status_code == 200
# Story moved
story = await db["stories"].find_one({"_id": story_id})
assert story["section_id"] == str(section_b)
# Redirect created
redirect = await db["redirects"].find_one({"old_path": "blog/my-post"})
assert redirect is not None
assert redirect["new_path"] == "archive/my-post"
@pytest.mark.asyncio
async def test_move_slug_collision_returns_409(self, async_client, mock_auth):
section_a = ObjectId()
section_b = ObjectId()
await db["sections"].insert_many([
{"_id": section_a, "slug": "a", "path": "a", "parent_id": None,
"display_type": "feed", "is_published": True, "sort_order": 0, "title": "A"},
{"_id": section_b, "slug": "b", "path": "b", "parent_id": None,
"display_type": "feed", "is_published": True, "sort_order": 0, "title": "B"},
])
story_1 = ObjectId()
story_2 = ObjectId()
await db["stories"].insert_one({
"_id": story_1, "slug": "post", "title": "Post 1",
"section_id": str(section_a), "is_published": True,
})
await db["stories"].insert_one({
"_id": story_2, "slug": "post", "title": "Post 2",
"section_id": str(section_b), "is_published": True,
})
response = await async_client.put(
f"/content/story/{story_1}/move",
json={"target_section_id": str(section_b)},
headers={"Authorization": "Bearer mock-token"},
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_redirect_flattening(self, async_client, mock_auth):
"""Moving A->B then B->C flattens A->C."""
sections = [ObjectId() for _ in range(3)]
for i, s in enumerate(sections):
await db["sections"].insert_one({
"_id": s, "slug": f"s{i}", "path": f"s{i}", "parent_id": None,
"display_type": "feed", "is_published": True, "sort_order": 0, "title": f"S{i}",
})
story_id = ObjectId()
await db["stories"].insert_one({
"_id": story_id, "slug": "post", "title": "Post",
"section_id": str(sections[0]), "is_published": True,
})
# Move to s1
await async_client.put(
f"/content/story/{story_id}/move",
json={"target_section_id": str(sections[1])},
headers={"Authorization": "Bearer mock-token"},
)
# Move to s2
await async_client.put(
f"/content/story/{story_id}/move",
json={"target_section_id": str(sections[2])},
headers={"Authorization": "Bearer mock-token"},
)
# Original redirect should point directly to s2, not s1
redirect = await db["redirects"].find_one({"old_path": "s0/post"})
assert redirect["new_path"] == "s2/post"Step 2: Run — fail. Step 3: Implement move handler with redirect flattening. Step 4: Run — pass.
Step 5: Commit
git add backend/handlers/moves.py backend/app.py backend/tests/test_moves.py
git commit -m "feat: content move endpoint with redirects and flattening"Task 4.2: Section Move Endpoint
Files:
- Modify:
backend/handlers/moves.py - Test:
backend/tests/test_moves.py
Step 1: Write failing tests
class TestSectionMove:
@pytest.mark.asyncio
async def test_move_section_updates_paths(self, async_client, mock_auth):
"""Moving a section cascades path updates and creates redirects."""
root_a = ObjectId()
root_b = ObjectId()
child = ObjectId()
await db["sections"].insert_many([
{"_id": root_a, "slug": "a", "path": "a", "parent_id": None,
"display_type": "feed", "is_published": True, "sort_order": 0, "title": "A"},
{"_id": root_b, "slug": "b", "path": "b", "parent_id": None,
"display_type": "feed", "is_published": True, "sort_order": 0, "title": "B"},
{"_id": child, "slug": "child", "path": "a/child", "parent_id": str(root_a),
"display_type": "feed", "is_published": True, "sort_order": 0, "title": "Child"},
])
await db["stories"].insert_one({
"_id": ObjectId(), "slug": "post", "title": "Post",
"section_id": str(child), "is_published": True,
})
response = await async_client.put(
f"/sections/{child}/move",
json={"target_parent_id": str(root_b)},
headers={"Authorization": "Bearer mock-token"},
)
assert response.status_code == 200
updated = await db["sections"].find_one({"_id": child})
assert updated["path"] == "b/child"
assert updated["parent_id"] == str(root_b)
# Redirect for section
assert await db["redirects"].find_one({"old_path": "a/child"}) is not None
# Redirect for content in section
assert await db["redirects"].find_one({"old_path": "a/child/post"}) is not None
@pytest.mark.asyncio
async def test_cycle_detection(self, async_client, mock_auth):
"""Moving a section into its own descendant returns 400."""
parent = ObjectId()
child = ObjectId()
await db["sections"].insert_many([
{"_id": parent, "slug": "parent", "path": "parent", "parent_id": None,
"display_type": "feed", "is_published": True, "sort_order": 0, "title": "Parent"},
{"_id": child, "slug": "child", "path": "parent/child", "parent_id": str(parent),
"display_type": "feed", "is_published": True, "sort_order": 0, "title": "Child"},
])
response = await async_client.put(
f"/sections/{parent}/move",
json={"target_parent_id": str(child)},
headers={"Authorization": "Bearer mock-token"},
)
assert response.status_code == 400Step 2-4: Red-green cycle. Step 5: Commit.
git commit -m "feat: section move with cascade, cycle detection, and bulk redirects"Task 4.3: Redirect Resolution in resolve-path
Files:
- Modify:
backend/handlers/path_resolution.py - Test:
backend/tests/test_path_resolution.py
Step 1: Write failing test
class TestResolvePathRedirect:
@pytest.mark.asyncio
async def test_redirect_returns_301(self, async_client):
await db["redirects"].insert_one({
"old_path": "blog/old-post",
"new_path": "archive/old-post",
"created_at": datetime.now(timezone.utc),
})
response = await async_client.get(
"/sections/resolve-path/blog/old-post",
follow_redirects=False,
)
assert response.status_code == 301
assert response.headers["location"] == "/archive/old-post"
@pytest.mark.asyncio
async def test_expired_redirect_returns_404(self, async_client):
await db["redirects"].insert_one({
"old_path": "blog/expired",
"new_path": "archive/expired",
"created_at": datetime.now(timezone.utc),
"expires_at": datetime(2020, 1, 1, tzinfo=timezone.utc),
})
response = await async_client.get("/sections/resolve-path/blog/expired")
assert response.status_code == 404Step 2-4: Red-green. Step 5: Commit.
Add redirect lookup as final fallback in resolve_path, before the 404:
# Check redirects
redirect = await db["redirects"].find_one({"old_path": path})
if redirect:
if redirect.get("expires_at") and redirect["expires_at"] < datetime.now(timezone.utc):
raise HTTPException(status_code=404, detail="Path not found")
return RedirectResponse(url=f"/{redirect['new_path']}", status_code=301)git commit -m "feat: redirect resolution in resolve-path endpoint"Phase 5: Frontend Routing Rewrite
Task 5.1: Next.js API Proxy for resolve-path
Files:
- Create:
frontend/src/pages/api/sections/resolve-path/[...path].ts - Test: Manual + e2e
Step 1: Create the proxy route
import { NextApiRequest, NextApiResponse } from 'next';
import { fetchBackend, getAccessToken } from '@/shared/utils/backend-fetch';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const pathSegments = req.query.path;
const fullPath = Array.isArray(pathSegments) ? pathSegments.join('/') : pathSegments;
if (!fullPath) {
return res.status(400).json({ detail: 'Path is required' });
}
try {
const accessToken = await getAccessToken(req);
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
const response = await fetchBackend(`/sections/resolve-path/${fullPath}`, {
method: 'GET',
headers,
});
if (response.status === 301) {
const location = response.headers.get('location');
return res.redirect(301, location || '/');
}
if (response.status === 404) {
return res.status(404).json({ detail: 'Path not found' });
}
const data = await response.json();
return res.status(200).json(data);
} catch (error) {
return res.status(500).json({ detail: 'Internal server error' });
}
}Step 2: Manual test
Run: make dev-local
Visit: http://localhost:3000/api/sections/resolve-path/blog
Expected: JSON response with section data
Step 3: Commit
git add frontend/src/pages/api/sections/resolve-path/
git commit -m "feat: Next.js API proxy for resolve-path endpoint"Task 5.2: Next.js API Proxy for Children Endpoint
Files:
- Create:
frontend/src/pages/api/sections/[id]/children.ts
Follow the same pattern as Task 5.1 but proxying to /sections/{id}/children with query params forwarded.
Commit:
git commit -m "feat: Next.js API proxy for section children endpoint"Task 5.3: Rewrite Catch-All Route
Files:
- Modify:
frontend/src/pages/[...slugPath].tsx - Test:
frontend/src/pages/__tests__/slugPath.test.tsx(vitest)
Step 1: Write failing vitest tests
Create frontend/src/pages/__tests__/slugPath.test.tsx:
import { describe, it, expect, vi } from 'vitest';
// Test the resolution logic, not the full SSR
describe('slugPath resolution', () => {
it('calls resolve-path with joined slug segments', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({
type: 'section',
section: { path: 'blog', display_type: 'feed', title: 'Blog' },
breadcrumbs: [{ title: 'Blog', path: 'blog' }],
}),
});
// Test that the path joining works correctly
const slugPath = ['creative-work', 'photography', 'portraits'];
const joinedPath = slugPath.join('/');
expect(joinedPath).toBe('creative-work/photography/portraits');
});
it('handles redirect response', () => {
// Test redirect handling shape
const redirectResult = {
redirect: { destination: '/new/path', permanent: true },
};
expect(redirectResult.redirect.permanent).toBe(true);
});
});Step 2: Rewrite getServerSideProps
Replace the current multi-step resolution in [...slugPath].tsx:
export const getServerSideProps: GetServerSideProps = async (context) => {
const slugPath = context.params?.slugPath as string[];
if (!slugPath || slugPath.length === 0) {
return { notFound: true };
}
const fullPath = slugPath.join('/');
const backendUrl = getBackendUrl();
const response = await fetch(`${backendUrl}/sections/resolve-path/${fullPath}`);
if (response.status === 301) {
const location = response.headers.get('location');
return {
redirect: { destination: location || '/', permanent: true },
};
}
if (response.status === 404 || !response.ok) {
return { notFound: true };
}
const data = await response.json();
// For section views, fetch children
if (data.type === 'section') {
const childrenResp = await fetch(
`${backendUrl}/sections/${data.section.id}/children?limit=20&offset=0`
);
if (childrenResp.ok) {
data.children = await childrenResp.json();
}
}
return {
props: {
resolvedData: data,
session: await getServerSession(context.req, context.res, authOptions),
},
};
};Step 3: Update page component
The page component branches on resolvedData.type:
"section"— render usingdisplayRegistry[section.display_type]"content"— render detail view usingcontentRegistry[content_item.content_type]
Step 4: Run tests
make test-frontend-unitStep 5: Manual verification
make dev-localNavigate to existing section URLs (/blog, /projects, /about) — they should still work.
Step 6: Commit
git commit -m "feat: rewrite catch-all route to use resolve-path endpoint"Phase 6: Display Component Refactor
Task 6.1: Unified ListingCard Component
Files:
- Create:
frontend/src/modules/registry/components/ListingCard.tsx - Test:
frontend/src/modules/registry/components/__tests__/ListingCard.test.tsx
Step 1: Write failing vitest test
import { render, screen } from '@testing-library/react';
import { ListingCard } from '../ListingCard';
describe('ListingCard', () => {
const baseItem = {
id: '1', slug: 'test', item_type: 'content' as const,
content_type: 'story', title: 'Test Post',
created_at: '2026-03-29T00:00:00Z',
updated_at: '2026-03-29T00:00:00Z',
is_published: true, is_featured: false, tags: [],
};
it('renders title', () => {
render(<ListingCard item={baseItem} sectionPath="blog" />);
expect(screen.getByText('Test Post')).toBeInTheDocument();
});
it('renders summary when present', () => {
render(<ListingCard item={{ ...baseItem, summary: 'A summary' }} sectionPath="blog" />);
expect(screen.getByText('A summary')).toBeInTheDocument();
});
it('omits image when not present', () => {
render(<ListingCard item={baseItem} sectionPath="blog" />);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
it('renders image when present', () => {
render(<ListingCard item={{ ...baseItem, image_url: '/img.webp' }} sectionPath="blog" />);
expect(screen.getByRole('img')).toBeInTheDocument();
});
it('links to correct path', () => {
render(<ListingCard item={baseItem} sectionPath="blog" />);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/blog/test');
});
it('renders tags', () => {
render(<ListingCard item={{ ...baseItem, tags: ['python', 'react'] }} sectionPath="blog" />);
expect(screen.getByText('python')).toBeInTheDocument();
expect(screen.getByText('react')).toBeInTheDocument();
});
});Step 2-4: Red-green cycle.
Step 5: Commit
git commit -m "feat: unified ListingCard component for all content types"Task 6.2: Update Display Components to Use ListingCard
Files:
- Modify:
frontend/src/modules/registry/components/CardGridDisplay.tsx - Modify:
frontend/src/modules/registry/components/FeedDisplay.tsx - Modify:
frontend/src/modules/registry/components/GalleryDisplay.tsx - Test: Vitest for each + manual
Replace per-type card components (StoryCard, ProjectCard) with ListingCard in each display component. The display components receive ListingItem[] from the children endpoint instead of type-specific arrays.
Test each component individually:
cd frontend && npx vitest run src/modules/registry/components/__tests__/Manual test: make dev-local, browse each section type.
Commit:
git commit -m "refactor: display components use ListingCard instead of type-specific cards"Task 6.3: Content Type Picker for New Content
Files:
- Create:
frontend/src/modules/editor/components/ContentTypePicker.tsx - Modify:
frontend/src/components/TopNav.tsx(update “New” button) - Test: Vitest
Since sections no longer have content_type, the “New” button must let the user choose what type of content to create. A simple dropdown or modal with the available types.
Commit:
git commit -m "feat: content type picker for new content in sections"Phase 7: Seed Data, E2E Tests & Manual Testing
Task 7.1: Seed Nested Section Hierarchy
Files:
- Create:
backend/migrations/0017_seed_nested_sections.py
Seed the hierarchy from the testing strategy doc:
/ (root)
├── blog/ (feed)
│ ├── tech/ (feed)
│ └── personal/ (feed)
├── creative-work/ (card-grid)
│ ├── photography/ (gallery)
│ │ └── portraits/ (gallery)
│ └── writing/ (feed)
├── projects/ (card-grid)
├── about/ (static-page)
└── contact/ (static-page)Each section gets 2-3 content items. Mix types where appropriate.
Test: make dev-local && make migrate, then browse every section.
Commit:
git commit -m "feat: seed nested section hierarchy for local development"Task 7.2: Update E2E Test Data
Files:
- Modify:
frontend/e2e/test-data.ts - Modify:
frontend/e2e/mock-server.ts - Modify:
frontend/e2e/fixtures/api-mock.fixture.ts
Update the mock server to serve:
GET /sections/resolve-path/:path— return test data based on pathGET /sections/:id/children— return mixed listing items- Redirect responses for moved content paths
Update test-data.ts with nested section hierarchy matching the seed data.
Commit:
git commit -m "test: update e2e mocks for nested routing and resolve-path"Task 7.3: E2E Tests — Navigation & Routing
Files:
- Create:
frontend/e2e/specs/nested-routing/navigation.spec.ts - Create:
frontend/e2e/page-objects/section.page.ts
import { test, expect } from '../../fixtures';
test.describe('Nested Routing', () => {
test('top-level section loads', async ({ mockApiPage }) => {
await mockApiPage.goto('/blog');
await expect(mockApiPage.locator('[data-testid="section-title"]')).toHaveText('Blog');
});
test('nested section loads', async ({ mockApiPage }) => {
await mockApiPage.goto('/creative-work/photography');
await expect(mockApiPage.locator('[data-testid="section-title"]')).toHaveText('Photography');
});
test('content item detail loads', async ({ mockApiPage }) => {
await mockApiPage.goto('/blog/my-published-story');
await expect(mockApiPage.locator('[data-testid="content-title"]')).toBeVisible();
});
test('deeply nested content loads', async ({ mockApiPage }) => {
await mockApiPage.goto('/creative-work/photography/portraits/beach-sunset');
await expect(mockApiPage.locator('[data-testid="content-title"]')).toBeVisible();
});
test('breadcrumbs show full path', async ({ mockApiPage }) => {
await mockApiPage.goto('/creative-work/photography/portraits');
const breadcrumbs = mockApiPage.locator('[data-testid="breadcrumbs"] a');
await expect(breadcrumbs).toHaveCount(3);
});
test('404 for nonexistent path', async ({ mockApiPage }) => {
await mockApiPage.goto('/does/not/exist');
await expect(mockApiPage.locator('[data-testid="not-found"]')).toBeVisible();
});
});Commit:
git commit -m "test: e2e tests for nested routing navigation"Task 7.4: E2E Tests — Redirects
Files:
- Create:
frontend/e2e/specs/nested-routing/redirects.spec.ts
test.describe('Redirects', () => {
test('old URL redirects to new location', async ({ mockApiPage }) => {
// Mock server returns 301 for this path
await mockApiPage.goto('/blog/moved-post');
await expect(mockApiPage).toHaveURL(/\/archive\/moved-post/);
});
test('address bar shows new URL after redirect', async ({ mockApiPage }) => {
await mockApiPage.goto('/blog/moved-post');
expect(mockApiPage.url()).toContain('/archive/moved-post');
});
});Commit:
git commit -m "test: e2e tests for redirect resolution"Task 7.5: E2E Tests — Mixed Content Sections
Files:
- Create:
frontend/e2e/specs/nested-routing/mixed-content.spec.ts
test.describe('Mixed Content Sections', () => {
test('section renders mixed content types', async ({ mockApiPage }) => {
await mockApiPage.goto('/creative-work');
const cards = mockApiPage.locator('[data-testid="listing-card"]');
await expect(cards).toHaveCount.greaterThan(0);
});
test('clicking story card opens story detail', async ({ mockApiPage }) => {
await mockApiPage.goto('/blog');
await mockApiPage.locator('[data-testid="listing-card"]').first().click();
await expect(mockApiPage.locator('[data-testid="content-detail"]')).toBeVisible();
});
});Commit:
git commit -m "test: e2e tests for mixed content sections"Task 7.6: Full Test Suite Run & Manual Verification
Step 1: Run all automated tests
make format
make test
make test-frontend-unit
docker compose stop frontend && make test-frontendAll must pass.
Step 2: Manual testing checklist
Run make dev-local and verify each item:
- Navigate to every seeded section at every depth level
- Click through to content items from list views
- Create content in a nested section, verify URL
- Move content between sections (via API/admin), verify old URL redirects
- Rename a section, verify all child URLs redirect
- Check breadcrumbs at every level
- Verify 404 page for non-existent paths
- Test with both light and dark mode
- Test mobile navigation with nested sections
- Verify the content type picker works in nested sections
- Upload an image in a nested section, verify it uses type-based paths
- Verify existing top-level sections still work (
/blog,/projects,/about,/contact)
Step 3: Fix any issues found, commit each fix.
Phase 8: Cleanup
Task 8.1: Remove Dead Code
Files:
- Remove or clean: Old per-type fetching logic in
[...slugPath].tsx(replaced by resolve-path) - Remove:
content_typereferences from section-related frontend code - Remove: Old API proxy routes that are no longer needed (if any)
- Clean: Unused imports, type definitions referencing
content_typeon sections
Commit:
git commit -m "chore: remove dead code from flat routing system"Task 8.2: Update Architecture Docs
Files:
- Modify:
docs-site/pages/architecture/overview.mdx - Create:
docs-site/pages/adr/0007-nested-routing.mdx - Modify:
docs-site/pages/adr/_meta.ts
Commit:
git commit -m "docs: update architecture docs for nested routing"Task 8.3: Final Format & Test
make format
make test
make test-frontend-unit
docker compose stop frontend && make test-frontendAll passing. Ready for PR.