Jay Griffin
  • Home
  • Apps
  • Posts
  • Docs
  • Blocks
Design Document

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.

Single-table JSON
What we considered first

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.

Table-per-type
What we're doing

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.

blocks — shared tree skeleton
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)
);
page_properties — page-specific columns
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

ColumnTypePurpose
idTEXT PKUUID v4 via crypto.randomUUID() — the stable identifier for all references
typeTEXTBlock type discriminator. Initially just "page". Tells you which properties table to join.
contentTEXT? (JSON)Ordered JSON array of child block IDs. Determines rendering order in nav and listings.
parentTEXT? FKUUID of the parent block. NULL only for root /. FK to blocks.id.
created_atTEXTISO 8601 timestamp
updated_atTEXTISO 8601 timestamp

Column Reference — page_properties

ColumnTypePurpose
block_idTEXT PK FK1:1 with blocks.id. Also the primary key — enforces one properties row per block.
pathTEXT UNIQUE NOT NULLURL path — the natural lookup key. Enforced unique at the DB level.
titleTEXT NOT NULLDisplay name for breadcrumbs, nav, and metadata. Cannot be null.
descriptionTEXT?OG / meta description, optional
iconTEXT?Lucide icon name for sidebar/nav (e.g. "droplets", "cpu")
hiddenBOOLEANIf 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.

/pageHome · parent: null
├─/appspageApps
├─/poolpagePool
├─/cpu-ladderpageCPU Ladder
└─/deep-divepageDeep Dive
├─/postspagePosts
└─/posts/[slug] (dynamic — not in DB)
├─/docspageDocs
└─/docs/[slug] (dynamic — not in DB)
├─/blockspageBlocks
├─/blocks/design-docpageDesign Document
└─/blocks/breadcrumbspageBreadcrumbs (this page)
└─/adminpageAdmin (hidden)
├─/admin/contentpageContent
├─/admin/poolpagePool Admin
└─/admin/loginpageLogin (hidden)

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

getBlockById(id: string) → Block | null

Direct lookup by UUID primary key on the blocks table. The foundation query.

getPageByPath(path: string) → PageBlock | null

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.

getAncestors(id: string) → PageBlock[]

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.

getAncestorsByPath(path: string) → PageBlock[]

Convenience wrapper: calls getPageByPath() then getAncestors(). This is what PageBreadcrumb actually calls — pass a URL path, get the ancestor chain back.

getChildren(id: string) → PageBlock[]

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.

getRootChildren() → PageBlock[]

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

PropTypeDescription
pathstringThe current page's URL path. For dynamic slug pages, pass the parent section path (e.g. "/posts").
currentTitlestring?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 (joins blocks + page_properties).
  • Call getAncestors(block.id) to get the ancestor chain — returns [root, ..., parent], each with path and title from the joined properties.
  • Skip root — the / block is never shown in the breadcrumb trail.
  • Render each ancestor (except root) as a BreadcrumbLink pointing to its path.
  • Render the current page as a BreadcrumbPage (plain text, not a link).
  • If currentTitle is provided, use it as the final crumb label instead of the block's title. The block at path becomes an ancestor link, and currentTitle becomes the current page text.
  • Separate crumbs with BreadcrumbSeparator (renders a chevron).

Examples

<PageBreadcrumb path="/pool" />
Apps/Pool
<PageBreadcrumb path="/cpu-ladder" />
Apps/CPU Ladder
<PageBreadcrumb path="/posts" currentTitle="Hello World" />
Posts/Hello World
<PageBreadcrumb path="/blocks/breadcrumbs" />
Blocks/Breadcrumbs
<PageBreadcrumb path="/admin/content" />
Admin/Content

Route Coverage

Every route in the app and how it receives breadcrumbs.

RouteApproachTrail
/skipRoot — no breadcrumb
/appspath="/apps"Apps
/poolpath="/pool"Apps / Pool
/cpu-ladderpath="/cpu-ladder"Apps / CPU Ladder
/deep-diveskipClient component — needs refactoring first
/postspath="/posts"Posts
/posts/[slug]path="/posts" currentTitle={title}Posts / Article Title
/docspath="/docs"Docs
/docs/[slug]path="/docs" currentTitle={title}Docs / Doc Title
/blockspath="/blocks"Blocks
/blocks/*path="/blocks/..."Blocks / Sub-page
/admin/*via admin/layout.tsxAdmin / Content, Admin / Pool
/admin/loginskipHidden login page — no breadcrumb
/pool/testskipDev-only test page — no breadcrumb

Implementation Phases

Four phases, done incrementally. Each phase produces a working state.

1
Database
Migration + seed
  • Create 003-blocks-table.sql with both blocks and page_properties tables
  • 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
2
Module
Types + queries
  • src/modules/blocks/types.ts — Block, PageProperties, PageBlock types
  • src/modules/blocks/queries.ts — 6 query functions using JOIN-based lookups
  • Follow content/queries.ts pattern: db import, parseBlock() helper, parameterized SQL
3
Component
PageBreadcrumb
  • src/components/layout/PageBreadcrumb.tsx — async server component
  • Co-located PageBreadcrumb.module.css
  • Calls getAncestorsByPath(), renders with shadcn Breadcrumb* primitives
  • Handles both static pages and dynamic currentTitle override
4
Wire Pages
Add breadcrumbs to all routes
  • Static pages: <PageBreadcrumb path="/pool" />
  • Dynamic pages: pass currentTitle from content query
  • Admin pages: add to admin/layout.tsx
  • Skip: /, /deep-dive, /pool/test, /admin/login

TypeScript Types

Module: src/modules/blocks/types.ts

// The shared block tree row type Block = { id: string; type: string; content: string[] | null; parent: string | null; created_at: string; updated_at: string; } // The page_properties row type PageProperties = { block_id: string; path: string; title: string; description: string | null; icon: string | null; hidden: boolean; } // Joined result: block + page_properties type PageBlock = Block & PageProperties

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.

FileActionPurpose
src/modules/db/migrations/003-blocks-table.sqlcreateblocks + page_properties DDL
src/modules/blocks/types.tscreateBlock, PageProperties, PageBlock
src/modules/blocks/queries.tscreate6 query functions (JOIN-based) + parseBlock helper
src/modules/blocks/seed.tscreateInsert ~15 blocks + page_properties rows in transaction
src/components/layout/PageBreadcrumb.tsxcreateAsync server breadcrumb component
src/components/layout/PageBreadcrumb.module.csscreateBreadcrumb component styles
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/apps/page.tsxmodifyAdd breadcrumb
src/app/pool/page.tsxmodifyAdd breadcrumb
src/app/cpu-ladder/page.tsxmodifyAdd breadcrumb
src/app/posts/page.tsxmodifyAdd breadcrumb
src/app/posts/[slug]/page.tsxmodifyAdd breadcrumb with currentTitle
src/app/docs/page.tsxmodifyAdd breadcrumb
src/app/docs/[slug]/page.tsxmodifyAdd breadcrumb with currentTitle
src/app/blocks/page.tsxmodifyAdd breadcrumb
src/app/admin/layout.tsxmodifyAdd breadcrumb for all admin routes

Verification

Commands and checks to run after each phase, in order.

1. Run migration

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

2. Seed data

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

3. Verify seed

SELECT b.id, b.type, pp.path, pp.title, b.parent FROM blocks b JOIN page_properties pp ON pp.block_id = b.id ORDER BY pp.path;

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 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
Last updated: April 2026 · Breadcrumb implementation plan using the table-per-type blocks data model