Blocks
A Notion-inspired page tree that powers breadcrumbs, navigation, and eventually infinite nested pages. Everything is a block.
Core Concept
Inspired by “The Data Model Behind Notion”, every page in the app is a block with two pointers:
Each block stores its parent path. Walk the chain upward to build breadcrumbs:/pool → /apps → /
Each block stores a content JSON array of child paths. Determines ordering in nav and listings.
["/pool", "/cpu-ladder", "/deep-dive"]
Type determines rendering, not structure. A section, app, and admin page all share the same shape — the type field tells the UI how to display them.
Page Tree
The complete page hierarchy as it will exist in the database. Dynamic slug pages (/posts/[slug], /docs/[slug]) are not stored — they derive breadcrumbs from their parent section at runtime.
Database Schema
Migration 003-pages-table.sql — Turso (libsql), following the existing migration pattern.
CREATE TABLE pages (
id TEXT PRIMARY KEY, -- UUID v4
path TEXT UNIQUE NOT NULL, -- "/pool", "/apps"
type TEXT NOT NULL, -- "page" | "section" | "app" | "admin"
title TEXT NOT NULL, -- "Pool", "CPU Ladder"
description TEXT, -- for metadata / OG
icon TEXT, -- lucide icon name
parent TEXT, -- FK → pages.path (NULL for root)
content TEXT, -- JSON array of child paths
hidden BOOLEAN DEFAULT 0, -- exclude from nav
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (parent) REFERENCES pages(path)
);Column Reference
| Column | Type | Purpose |
|---|---|---|
id | TEXT PK | UUID v4 — stable identifier for future API / block operations |
path | TEXT UNIQUE | URL path — the natural key for a URL-routed app. Used as the FK target for parent. |
type | TEXT | Determines rendering: page, section, app, admin |
title | TEXT | Display name for breadcrumbs, nav, and metadata |
description | TEXT? | OG / meta description, optional |
icon | TEXT? | Lucide icon name for sidebar/nav (e.g. "droplets", "cpu") |
parent | TEXT? FK | Path of the parent page. NULL only for root /. |
content | TEXT? | JSON array of child paths, ordered. Parsed at query time. |
hidden | BOOLEAN | If 1, excluded from public nav/sidebar |
Query API
Module: src/modules/pages/queries.ts — follows the pattern from src/modules/content/queries.ts.
Single page lookup by exact path match. The foundation for everything else.
Walk parent pointers upward. Returns ordered array [root, ..., current]. This is the breadcrumb query. Max depth ~4, so iterative walks are fine without recursive CTE.
Parse the content JSON array and fetch those pages in order. Used for listing sub-pages and generating nav sections.
Top-level visible pages: WHERE parent = '/' AND hidden = 0. Future sidebar data source — replaces the hardcoded navItems array.
Breadcrumb Behavior
Component: PageBreadcrumb — async server component. Uses the existing shadcn Breadcrumb primitives from @/components/ui/breadcrumb.
Props
| Prop | Type | Description |
|---|---|---|
path | string | The current page's path. For dynamic pages, pass the parent section path (e.g. "/posts"). |
currentTitle | string? | Override the last breadcrumb label. Used by /posts/[slug] and /docs/[slug] to append the article's title without the slug page being in the DB. |
Rules: Root / is never shown in the trail. All non-root pages get breadcrumbs. Ancestors are clickable links. The current page is plain text (not a link).
Implementation Phases
Incremental migration — breadcrumbs first, not everything at once.
- Create
003-pages-table.sql - Run migration via
run-migration.ts - Create
seed.ts— insert ~11 static pages - Verify with SELECT query
src/modules/pages/types.ts—PageRowtypesrc/modules/pages/queries.ts— 4 query functions- Follow
content/queries.tspattern
PageBreadcrumb.tsx— async server component- Calls
getAncestors() - Renders with shadcn
Breadcrumb*primitives
- Static pages:
<PageBreadcrumb path="/pool" /> - Dynamic pages: add
currentTitleprop - All non-root pages get breadcrumbs
navItems with getRootPages() query. Icons from the icon column. Not this PR.Key Decisions
path is UNIQUE and used as the parent FK reference — not UUID. In a URL-routed app you always know the path, so you never need to look up an ID first. Simpler queries, simpler mental model.
/posts/[slug] and /docs/[slug] are not in the pages table. Their breadcrumbs come from the parent section + a runtime currentTitle prop. This avoids syncing two tables.
Could have been a static TypeScript file, but we chose Turso from the start. The tree is tiny (~11 rows) but this sets the foundation for admin UI editing, dynamic page creation, and permission gates later.
Ordered children stored as a JSON array of paths in the content column. Could be a join table later but overkill for ~15 rows. Parsed at query time via JSON.parse().
getAncestors() makes one query per tree level (max ~4). No recursive CTE needed for this depth. Optimize later if the tree gets deep.
Turso is fast and the tree is tiny. getAncestors() hits the DB on every page load — fine for now. Add React.cache or unstable_cache later if needed.
File Map
Every file involved in this feature — what to create, what to modify, what to reuse.
| File | Action | Purpose |
|---|---|---|
src/modules/db/migrations/003-pages-table.sql | create | Pages table DDL |
src/modules/pages/types.ts | create | PageRow type definition |
src/modules/pages/queries.ts | create | 4 query functions + parseRow helper |
src/modules/pages/seed.ts | create | One-time script to insert ~11 page rows |
src/components/layout/PageBreadcrumb.tsx | create | Async server breadcrumb component |
src/modules/db/run-migration.ts | reuse | Existing generic migration runner |
src/modules/db/turso.ts | reuse | Existing DB client — import db |
src/components/ui/breadcrumb.tsx | reuse | Existing shadcn breadcrumb primitives |
src/app/pool/page.tsx | modify | Add breadcrumb |
src/app/cpu-ladder/page.tsx | modify | Add breadcrumb |
src/app/posts/[slug]/page.tsx | modify | Add breadcrumb with currentTitle |
src/app/docs/[slug]/page.tsx | modify | Add breadcrumb with currentTitle |
Verification
Commands to run after implementation, in order.
1. Run migration
2. Seed data
3. Verify seed
4. Build check
5. Manual verification
- Navigate to
/pool→ breadcrumb shows Apps / Pool - Navigate to
/cpu-ladder→ breadcrumb shows Apps / CPU Ladder - Navigate to
/posts/hello-world→ breadcrumb shows Posts / Hello World - Navigate to
/admin/content→ breadcrumb shows Admin / Content
Future
Things this data model unlocks once the foundation is in place.
- Sidebar from DB — replace hardcoded
navItemswithgetRootPages()+getChildren() - Infinite nesting — any page can be a parent. The schema already supports arbitrary depth.
- Admin page editor — CRUD UI for creating, reordering, and nesting pages. Update
contentarrays andparentpointers. - Permissions — walk the
parentchain to inherit access rules. Admin pages already havehidden: trueas a starting point. - Content table integration — add a
content_idFK to merge pages and content rows, or keep them bridged via props. - Caching — wrap
getAncestors()inReact.cacheorunstable_cacheonce traffic justifies it.