Page Builder Architecture
This document describes the technical architecture of the Page Builder system, including data flow, component relationships, and performance optimizations.
Integration with Generic Entity System
The Page Builder is not a standalone system - it integrates with the generic entity infrastructure:
┌─────────────────────────────────────────────────────────────────────┐
│ ENTITY SYSTEM INTEGRATION │
│ │
│ EntityConfig │
│ └── builder.enabled: true ────────────────────────────────────┐ │
│ │ │
│ System Fields (system-fields.ts) │ │
│ └── BUILDER_SYSTEM_FIELD_NAMES = ['blocks'] ←─────────────────┤ │
│ │ │
│ Schema Generator (schema-generator.ts) │ │
│ └── Adds blocks field if builder.enabled ←─────────────────────┤ │
│ │ │
│ Generic Handler (generic-handler.ts) │ │
│ └── Processes blocks with 3-condition validation ←────────────┘ │
│ 1. builder.enabled === true │
│ 2. x-builder-source header present │
│ 3. blocks in request payload │
└─────────────────────────────────────────────────────────────────────┘
The x-builder-source Header
Builder requests include a special header for identification:
// builder-editor-view.tsx
headers['x-builder-source'] = 'true'
The generic handler uses this header to determine when to process blocks:
// generic-handler.ts
function isBuilderRequest(request: NextRequest): boolean {
return request.headers.get('x-builder-source') === 'true'
}
// Only save blocks when ALL conditions are true
if (entityConfig.builder?.enabled && isBuilderRequest(request) && 'blocks' in data) {
// Process blocks...
}
System Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ CLIENT (Browser) │
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ ADMIN EDITOR │ │
│ │ ┌──────────────┐ ┌──────────────────┐ ┌────────────────────────┐ │ │
│ │ │ BlockPicker │ │ BlockCanvas │ │ BlockSettingsPanel │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ • Search │ │ • SortableBlock │ │ • DynamicForm │ │ │
│ │ │ • Categories │ │ • DndContext │ │ • 3-Tab Structure │ │ │
│ │ │ • Blocks │ │ • Preview/Layout │ │ • ArrayField │ │ │
│ │ └──────────────┘ └──────────────────┘ └────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ TanStack Query │
│ │ │
└────────────────────────────────────┼─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ SERVER (Next.js) │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ GENERIC ENTITY API + HANDLER │ │
│ │ /api/v1/{entity} → List, Create (generic-handler.ts) │ │
│ │ /api/v1/{entity}/:id → Get, Update, Delete │ │
│ │ │ │
│ │ Builder-enabled entities: Pages, Posts │ │
│ │ x-builder-source header identifies builder requests │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ BUILD-TIME REGISTRY │ │
│ │ BLOCK_REGISTRY ← core/scripts/build/registry.mjs │ │
│ │ • Zero runtime I/O │ │
│ │ • Static imports at build time │ │
│ │ • ~17,255x faster than dynamic loading │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ DATABASE (PostgreSQL) │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ pages │ │
│ │ ├── id: UUID PRIMARY KEY │ │
│ │ ├── slug: VARCHAR(255) UNIQUE per locale │ │
│ │ ├── title: VARCHAR(255) │ │
│ │ ├── blocks: JSONB (BlockInstance[]) │ │
│ │ ├── locale: VARCHAR(10) DEFAULT 'en' │ │
│ │ ├── seo_title, seo_description, seo_keywords, og_image │ │
│ │ ├── published: BOOLEAN DEFAULT false │ │
│ │ ├── author_id: TEXT REFERENCES users(id) │ │
│ │ └── created_at, updated_at: TIMESTAMPTZ │ │
│ │ │ │
│ │ Indexes: slug, locale, published, author, created_at, blocks (GIN) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Data Flow
1. Editor → Database (Save)
User edits block
│
▼
┌──────────────────┐
│ DynamicForm │ ← User input
│ (debounced 500ms)│
└────────┬─────────┘
│
▼
┌──────────────────┐
│ BlockSettingsPanel│ ← onUpdateProps(blockId, props)
└────────┬─────────┘
│
▼
┌──────────────────┐
│ PageEditor │ ← setBlocks([...])
│ (local state) │ ← hasUnsavedChanges = true
└────────┬─────────┘
│
▼ (user clicks Save)
┌──────────────────┐
│ saveMutation │
│ TanStack Query │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ PATCH /api/v1/ │
│ pages/{id} │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ PostgreSQL │
│ blocks: JSONB │
└──────────────────┘
2. Database → Public Page (Render)
User visits /about-us
│
▼
┌──────────────────────────────┐
│ app/(public)/[entity]/page │
│ Check: Is this a page? │
└────────────┬─────────────────┘
│
▼
┌──────────────────────────────┐
│ SELECT FROM pages │
│ WHERE slug = 'about-us' │
│ AND published = true │
└────────────┬─────────────────┘
│
┌────────┴────────┐
│ Page Found? │
│ │
YES NO
│ │
▼ ▼
┌──────────┐ ┌──────────────┐
│PageRender│ │ Try Entity │
│Component │ │ Archive │
└────┬─────┘ └──────────────┘
│
▼
┌──────────────────────────────┐
│ For each block: │
│ • Get component from map │
│ • Normalize props │
│ • Render with Suspense │
└──────────────────────────────┘
Directory Structure
nextspark/
├── app/
│ ├── api/v1/pages/
│ │ ├── route.ts # GET (list), POST (create)
│ │ ├── [id]/route.ts # GET, PATCH, DELETE single page
│ │ └── validate-slug/route.ts
│ │
│ ├── dashboard/(main)/pages/
│ │ ├── page.tsx # Pages list
│ │ ├── new/page.tsx # Create page
│ │ └── [id]/edit/page.tsx # Page editor
│ │
│ ├── (public)/[entity]/
│ │ └── page.tsx # Public rendering (pages + entities)
│ │
│ └── components/
│ └── page-renderer.tsx # Block rendering component
│
├── core/
│ ├── types/
│ │ └── blocks.ts # BlockConfig, BlockInstance, schemas
│ │
│ ├── lib/registries/
│ │ └── block-registry.ts # Auto-generated BLOCK_REGISTRY
│ │
│ └── components/dashboard/block-editor/
│ ├── block-picker.tsx # Left panel - block selection
│ ├── block-canvas.tsx # Center - layout mode (dnd)
│ ├── block-preview-canvas.tsx # Center - preview mode
│ ├── block-settings-panel.tsx # Right panel - configuration
│ ├── dynamic-form.tsx # Auto-generated forms
│ ├── array-field.tsx # Repeater fields
│ ├── sortable-block.tsx # Draggable block wrapper
│ └── page-settings-panel.tsx # SEO settings
│
├── contents/themes/{theme}/blocks/
│ └── {block-slug}/
│ ├── config.ts # Block metadata
│ ├── fields.ts # Field definitions for admin
│ ├── schema.ts # Zod validation schema
│ ├── component.tsx # React component
│ ├── index.ts # Re-exports
│ └── thumbnail.png # Preview image
│
├── migrations/
│ ├── 008_pages_table.sql # Pages table schema
│ ├── 009_pages_metas.sql # Meta fields extension
│ └── 010_pages_sample_data.sql # Sample data
│
└── scripts/
└── build-registry.mjs # Generates block registry
Component Relationships
┌─────────────────────────────────────────────────────────────────────┐
│ PageEditor (page.tsx) │
│ State: blocks[], selectedBlockId, title, slug, viewMode │
│ │
│ ┌─────────────┐ ┌─────────────────┐ ┌────────────────────┐ │
│ │ BlockPicker │ │ View Toggle │ │ BlockSettingsPanel │ │
│ │ │ │ Layout/Preview │ │ │ │
│ │ onAddBlock │ └────────┬────────┘ │ block: selected │ │
│ │ │ │ │ │ onUpdateProps │ │
│ └─────┼───────┘ │ │ onRemove │ │
│ │ │ └─────────┬──────────┘ │
│ │ ┌──────────┴──────────┐ │ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────────┐ │
│ │ BlockCanvas │ │ BlockPreviewCanvas │ │
│ │ (Layout Mode) │ │ (Preview Mode) │ │
│ │ │ │ │ │
│ │ ┌─────────────────┐ │ │ ┌─────────────────────┐ │ │
│ │ │ SortableBlock │ │ │ │ SelectableBlock │ │ │
│ │ │ (dnd-kit) │ │ │ │ Preview │ │ │
│ │ │ onSelect │ │ │ │ BlockComponent │ │ │
│ │ │ onRemove │ │ │ │ (lazy loaded) │ │ │
│ │ │ onDuplicate │ │ │ └─────────────────────┘ │ │
│ │ └─────────────────┘ │ └─────────────────────────┘ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Performance Optimizations
1. Build-Time Block Registry
Instead of loading block definitions at runtime:
// ❌ Slow: Runtime file system I/O (~140ms)
const blocks = await loadBlocksFromFileSystem()
// ✅ Fast: Build-time static registry (~6ms)
import { BLOCK_REGISTRY } from '@/core/lib/registries/block-registry'
const blocks = Object.values(BLOCK_REGISTRY)
Performance improvement: ~17,255x faster
2. Lazy Loading Block Components
// Code-split block components
const HeroBlock = lazy(() =>
import('@/contents/themes/default/blocks/hero/component')
.then(m => ({ default: m.HeroBlock }))
)
3. Debounced Form Updates
// DynamicForm debounces onChange by 500ms
useEffect(() => {
const timer = setTimeout(() => {
onChange(formValues)
}, 500)
return () => clearTimeout(timer)
}, [formValues])
4. ISR for Public Pages
// 1-hour revalidation
export const revalidate = 3600
5. Database Optimizations
- GIN index on
blocksJSONB column for efficient queries - Composite index on
(slug, locale)for unique lookups - Partial index on
published = truefor public page queries
Integration Points
Entity System
The Page Builder shares the public route with the entity archive system:
// app/(public)/[entity]/page.tsx
// Priority: Pages first, then entity archives
const pageResult = await query('SELECT * FROM pages WHERE slug = $1', [slug])
if (pageResult.rows.length > 0) {
return <PageRenderer page={pageResult.rows[0]} />
}
// Otherwise, try entity archive
const resolution = await resolvePublicEntityFromUrl(`/${slug}`)
Theme System
Blocks are defined per-theme in contents/themes/{theme}/blocks/. The build registry discovers and registers all blocks from the active theme.
Registry System
Block registry follows the same pattern as entity registry:
- Auto-generated by
core/scripts/build/registry.mjs - Zero runtime I/O
- Type-safe exports
Next Steps
- Block System - Understand block types and schemas
- Creating Blocks - Build custom blocks
- Pages API - REST API reference
Performance Tip: The build-time registry approach eliminates ~140ms of file system I/O per request. Always use
BLOCK_REGISTRYinstead of dynamic imports.
Integration Note: The builder uses the generic entity handler (
generic-handler.ts) rather than custom API routes. This ensures consistency across all builder-enabled entities.
Last Updated: 2025-12-17 Version: 1.2.0 Status: Stable