Design Document

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:

↑ Parent
Upward pointer for ancestry

Each block stores its parent path. Walk the chain upward to build breadcrumbs:
/pool /apps /

↓ Content
Ordered children for rendering

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.

/pageHome
├─/appssectionApps
├─/poolappPool
├─/cpu-ladderappCPU Ladder
└─/deep-diveappDeep Dive
├─/postssectionPosts
└─/posts/[slug] (dynamic — not in DB)
├─/docssectionDocs
└─/docs/[slug] (dynamic — not in DB)
└─/adminadminAdmin (hidden)
├─/admin/contentadminContent
├─/admin/pooladminPool Admin
└─/admin/loginadminLogin (hidden)

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

ColumnTypePurpose
idTEXT PKUUID v4 — stable identifier for future API / block operations
pathTEXT UNIQUEURL path — the natural key for a URL-routed app. Used as the FK target for parent.
typeTEXTDetermines rendering: page, section, app, admin
titleTEXTDisplay name for breadcrumbs, nav, and metadata
descriptionTEXT?OG / meta description, optional
iconTEXT?Lucide icon name for sidebar/nav (e.g. "droplets", "cpu")
parentTEXT? FKPath of the parent page. NULL only for root /.
contentTEXT?JSON array of child paths, ordered. Parsed at query time.
hiddenBOOLEANIf 1, excluded from public nav/sidebar

Query API

Module: src/modules/pages/queries.ts — follows the pattern from src/modules/content/queries.ts.

getPageByPath(path: string) → PageRow | null

Single page lookup by exact path match. The foundation for everything else.

getAncestors(path: string) → PageRow[]

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.

getChildren(path: string) → PageRow[]

Parse the content JSON array and fetch those pages in order. Used for listing sub-pages and generating nav sections.

getRootPages() → PageRow[]

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.

/pool → getAncestors("/pool")
Apps/Pool
/cpu-ladder → getAncestors("/cpu-ladder")
Apps/CPU Ladder
/posts/hello-world → path="/posts" + currentTitle="Hello World"
Posts/Hello World
/admin/content → getAncestors("/admin/content")
Admin/Content

Props

PropTypeDescription
pathstringThe current page's path. For dynamic pages, pass the parent section path (e.g. "/posts").
currentTitlestring?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.

1
Database
Migration + seed
  • Create 003-pages-table.sql
  • Run migration via run-migration.ts
  • Create seed.ts — insert ~11 static pages
  • Verify with SELECT query
2
Module
Types + queries
  • src/modules/pages/types.ts PageRow type
  • src/modules/pages/queries.ts — 4 query functions
  • Follow content/queries.ts pattern
3
Breadcrumb
Server component
  • PageBreadcrumb.tsx — async server component
  • Calls getAncestors()
  • Renders with shadcn Breadcrumb* primitives
4
Wire Pages
Add breadcrumbs to all routes
  • Static pages: <PageBreadcrumb path="/pool" />
  • Dynamic pages: add currentTitle prop
  • All non-root pages get breadcrumbs
Deferred
Phase 5 — Sidebar Migration
Replace hardcoded navItems with getRootPages() query. Icons from the icon column. Not this PR.

Key Decisions

Path as natural key

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.

Dynamic slugs excluded

/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.

DB-backed from day 1

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.

Content as JSON array

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().

Iterative ancestor walks

getAncestors() makes one query per tree level (max ~4). No recursive CTE needed for this depth. Optimize later if the tree gets deep.

No caching yet

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.

FileActionPurpose
src/modules/db/migrations/003-pages-table.sqlcreatePages table DDL
src/modules/pages/types.tscreatePageRow type definition
src/modules/pages/queries.tscreate4 query functions + parseRow helper
src/modules/pages/seed.tscreateOne-time script to insert ~11 page rows
src/components/layout/PageBreadcrumb.tsxcreateAsync server breadcrumb component
src/modules/db/run-migration.tsreuseExisting generic migration runner
src/modules/db/turso.tsreuseExisting DB client — import db
src/components/ui/breadcrumb.tsxreuseExisting shadcn breadcrumb primitives
src/app/pool/page.tsxmodifyAdd breadcrumb
src/app/cpu-ladder/page.tsxmodifyAdd breadcrumb
src/app/posts/[slug]/page.tsxmodifyAdd breadcrumb with currentTitle
src/app/docs/[slug]/page.tsxmodifyAdd breadcrumb with currentTitle

Verification

Commands to run after implementation, in order.

1. Run migration

npx tsx --env-file=.env.local src/modules/db/run-migration.ts src/modules/db/migrations/003-pages-table.sql

2. Seed data

npx tsx --env-file=.env.local src/modules/pages/seed.ts

3. Verify seed

npx tsx --env-file=.env.local -e "import { db } from './src/modules/db/turso'; db.execute('SELECT path, title, parent FROM pages ORDER BY path').then(r => console.table(r.rows))"

4. Build check

npx next build

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 navItems with getRootPages() + 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 content arrays and parent pointers.
  • Permissions — walk the parent chain to inherit access rules. Admin pages already have hidden: true as a starting point.
  • Content table integration — add a content_id FK to merge pages and content rows, or keep them bridged via props.
  • Caching — wrap getAncestors() in React.cache or unstable_cache once traffic justifies it.
Last updated: April 2026 · Source of truth for the blocks data model