Breadcrumb Implementation
The first consumer of the blocks data model — a PageBreadcrumb server component that walks the block tree to render navigation breadcrumbs on every non-root page.
Overview
Every page in the app is represented as a row spanning two tables: a shared blocks table that holds the tree structure, and a page_properties table that holds page-specific columns with real database constraints.
This is the shared skeleton + type tables pattern. The blocks table owns the universal tree shape (id, type, parent, content). Each block type gets its own properties table with proper columns, indexes, and constraints — never a JSON blob.
Breadcrumbs are built by joining blocks with page_properties and walking the parent pointer chain upward. Path lookups hit an indexed UNIQUE column, not json_extract().
Why Table-Per-Type
“Everything is a block” doesn't mean everything goes in one table. The block model defines the tree structure — parent/child relationships. The properties of each type are fundamentally different and deserve their own schema.
Problem: No UNIQUE constraint on path inside JSON — the DB can't protect your data.
Problem: json_extract() lookups are full table scans. Expression indexes exist but are fragile.
Problem: No NOT NULL enforcement on required fields. A typo in a key name silently passes.
Problem: Every query for page data needs json_extract() calls, making SQL verbose and harder to reason about.
path UNIQUE NOT NULL — the DB enforces uniqueness and presence.
title NOT NULL — can't accidentally insert a page without a name.
Standard indexed columns — fast lookups, standard tooling, no json_extract().
Adding a new block type = CREATE TABLE for its properties. No ALTER TABLE on existing tables.
With ~15 rows the performance difference is zero. This is a data design decision, not a performance one — we want the DB to enforce correctness so the application layer doesn't have to.
Database Schema
Migration 003-blocks-table.sql — two tables in one migration. The tree lives in blocks, page-specific data lives in page_properties.
CREATE TABLE blocks (
id TEXT PRIMARY KEY, -- crypto.randomUUID()
type TEXT NOT NULL, -- "page" (future: "text", "heading", "image")
content TEXT, -- JSON array of child block IDs
parent TEXT, -- FK → blocks.id (NULL for root)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (parent) REFERENCES blocks(id)
);CREATE TABLE page_properties (
block_id TEXT PRIMARY KEY, -- FK → blocks.id (1:1)
path TEXT UNIQUE NOT NULL, -- "/pool", "/apps"
title TEXT NOT NULL, -- "Pool", "CPU Ladder"
description TEXT, -- OG / meta description
icon TEXT, -- lucide icon name
hidden BOOLEAN DEFAULT 0, -- exclude from nav
FOREIGN KEY (block_id) REFERENCES blocks(id)
);Column Reference — blocks
| Column | Type | Purpose |
|---|---|---|
id | TEXT PK | UUID v4 via crypto.randomUUID() — the stable identifier for all references |
type | TEXT | Block type discriminator. Initially just "page". Tells you which properties table to join. |
content | TEXT? (JSON) | Ordered JSON array of child block IDs. Determines rendering order in nav and listings. |
parent | TEXT? FK | UUID of the parent block. NULL only for root /. FK to blocks.id. |
created_at | TEXT | ISO 8601 timestamp |
updated_at | TEXT | ISO 8601 timestamp |
Column Reference — page_properties
| Column | Type | Purpose |
|---|---|---|
block_id | TEXT PK FK | 1:1 with blocks.id. Also the primary key — enforces one properties row per block. |
path | TEXT UNIQUE NOT NULL | URL path — the natural lookup key. Enforced unique at the DB level. |
title | TEXT NOT NULL | Display name for breadcrumbs, nav, and metadata. Cannot be null. |
description | TEXT? | OG / meta description, optional |
icon | TEXT? | Lucide icon name for sidebar/nav (e.g. "droplets", "cpu") |
hidden | BOOLEAN | If 1, excluded from public nav/sidebar |
Future block types get their own table: text_properties, image_properties, etc. Each with its own shape, constraints, and indexes. No ALTER TABLE on existing tables — just CREATE TABLE.
Page Tree
The complete hierarchy to seed. Each entry creates one row in blocks (type = "page") and one row in page_properties. Dynamic slug pages (/posts/[slug], /docs/[slug]) are not stored — they derive breadcrumbs from their parent section at runtime using the currentTitle prop.
~15 page blocks. Each creates one row in blocks and one in page_properties. The seed script inserts both in a transaction.
Query API
Module: src/modules/blocks/queries.ts — follows the pattern from src/modules/content/queries.ts. Page lookups join blocks with page_properties using an indexed path column — no json_extract().
Direct lookup by UUID primary key on the blocks table. The foundation query.
Join blocks with page_properties and look up by the indexed path column: WHERE pp.path = ?. The primary entry point for breadcrumbs — you always know the current URL.
Walk parent pointers upward from a block ID, joining page_properties at each step to get titles and paths. Returns ordered array [root, ..., grandparent, parent] — excludes the current block. This is the core breadcrumb query. Max depth ~4, so iterative walks are fine without recursive CTE.
Convenience wrapper: calls getPageByPath() then getAncestors(). This is what PageBreadcrumb actually calls — pass a URL path, get the ancestor chain back.
Parse the content JSON array on the parent block and fetch those blocks (joined with page_properties) in order. Used for listing sub-pages and generating nav sections. Not needed for breadcrumbs but included for completeness.
Top-level visible page blocks: WHERE b.parent = (root_id) AND pp.hidden = 0. Future sidebar data source — not needed for breadcrumbs but sets up the next phase.
PageBreadcrumb Component
An async server component in src/components/layout/PageBreadcrumb.tsx. Uses the existing shadcn Breadcrumb primitives from @/components/ui/breadcrumb.
Props
| Prop | Type | Description |
|---|---|---|
path | string | The current page's URL path. For dynamic slug pages, pass the parent section path (e.g. "/posts"). |
currentTitle | string? | Override the last breadcrumb segment. Used by /posts/[slug] and /docs/[slug] to append the article's title as the final crumb — without the slug page needing a row in the DB. |
Rendering Logic
- Call
getPageByPath(path)to find the current page block (joinsblocks+page_properties). - Call
getAncestors(block.id)to get the ancestor chain — returns[root, ..., parent], each withpathandtitlefrom the joined properties. - Skip root — the
/block is never shown in the breadcrumb trail. - Render each ancestor (except root) as a
BreadcrumbLinkpointing to its path. - Render the current page as a
BreadcrumbPage(plain text, not a link). - If
currentTitleis provided, use it as the final crumb label instead of the block's title. The block atpathbecomes an ancestor link, andcurrentTitlebecomes the current page text. - Separate crumbs with
BreadcrumbSeparator(renders a chevron).
Examples
Route Coverage
Every route in the app and how it receives breadcrumbs.
| Route | Approach | Trail |
|---|---|---|
/ | skip | Root — no breadcrumb |
/apps | path="/apps" | Apps |
/pool | path="/pool" | Apps / Pool |
/cpu-ladder | path="/cpu-ladder" | Apps / CPU Ladder |
/deep-dive | skip | Client component — needs refactoring first |
/posts | path="/posts" | Posts |
/posts/[slug] | path="/posts" currentTitle={title} | Posts / Article Title |
/docs | path="/docs" | Docs |
/docs/[slug] | path="/docs" currentTitle={title} | Docs / Doc Title |
/blocks | path="/blocks" | Blocks |
/blocks/* | path="/blocks/..." | Blocks / Sub-page |
/admin/* | via admin/layout.tsx | Admin / Content, Admin / Pool |
/admin/login | skip | Hidden login page — no breadcrumb |
/pool/test | skip | Dev-only test page — no breadcrumb |
Implementation Phases
Four phases, done incrementally. Each phase produces a working state.
- Create
003-blocks-table.sqlwith bothblocksandpage_propertiestables - Run migration via
run-migration.ts - Create
seed.ts— insert ~15 page blocks with UUIDs, parent pointers, and matching page_properties rows in a transaction - Verify with
SELECT b.id, pp.path, pp.title FROM blocks b JOIN page_properties pp ON pp.block_id = b.id
src/modules/blocks/types.ts—Block,PageProperties,PageBlocktypessrc/modules/blocks/queries.ts— 6 query functions using JOIN-based lookups- Follow
content/queries.tspattern: db import,parseBlock()helper, parameterized SQL
src/components/layout/PageBreadcrumb.tsx— async server component- Co-located
PageBreadcrumb.module.css - Calls
getAncestorsByPath(), renders with shadcnBreadcrumb*primitives - Handles both static pages and dynamic
currentTitleoverride
- Static pages:
<PageBreadcrumb path="/pool" /> - Dynamic pages: pass
currentTitlefrom content query - Admin pages: add to
admin/layout.tsx - Skip:
/,/deep-dive,/pool/test,/admin/login
TypeScript Types
Module: src/modules/blocks/types.ts
PageBlock is the flattened result of joining blocks with page_properties. No JSON.parse() needed — every field is a proper column with a known type. The parseBlock() helper only needs to handle JSON.parse() on content (the child ID array) and the hidden boolean coercion.
File Map
Every file involved — what to create, what to modify, what to reuse.
| File | Action | Purpose |
|---|---|---|
src/modules/db/migrations/003-blocks-table.sql | create | blocks + page_properties DDL |
src/modules/blocks/types.ts | create | Block, PageProperties, PageBlock |
src/modules/blocks/queries.ts | create | 6 query functions (JOIN-based) + parseBlock helper |
src/modules/blocks/seed.ts | create | Insert ~15 blocks + page_properties rows in transaction |
src/components/layout/PageBreadcrumb.tsx | create | Async server breadcrumb component |
src/components/layout/PageBreadcrumb.module.css | create | Breadcrumb component styles |
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/apps/page.tsx | modify | Add breadcrumb |
src/app/pool/page.tsx | modify | Add breadcrumb |
src/app/cpu-ladder/page.tsx | modify | Add breadcrumb |
src/app/posts/page.tsx | modify | Add breadcrumb |
src/app/posts/[slug]/page.tsx | modify | Add breadcrumb with currentTitle |
src/app/docs/page.tsx | modify | Add breadcrumb |
src/app/docs/[slug]/page.tsx | modify | Add breadcrumb with currentTitle |
src/app/blocks/page.tsx | modify | Add breadcrumb |
src/app/admin/layout.tsx | modify | Add breadcrumb for all admin routes |
Verification
Commands and checks to run after each phase, 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 a post → breadcrumb shows Posts / Article Title
- Navigate to
/admin/content→ breadcrumb shows Admin / Content - Navigate to
/blocks/design-doc→ breadcrumb shows Blocks / Design Document - Root
/→ no breadcrumb rendered