Content Platform — CMS Patterns¶
How GE builds headless CMS functionality for content platforms. Decision: build custom vs. integrate existing CMS.
Build vs. Integrate Decision¶
Build Custom CMS (Hono API + PostgreSQL)¶
IF: Content model is unique to client's domain THEN: Build custom — full control over schema, API, and editor
IF: Client needs tight integration with existing business logic THEN: Build custom — avoids double data layer
IF: Client has < 5 content types and straightforward workflows THEN: Build custom — faster than learning external CMS API
INCLUDES: Hono API routes for content CRUD INCLUDES: PostgreSQL with JSONB for flexible content fields INCLUDES: Tiptap editor for rich text INCLUDES: Next.js App Router for rendering INCLUDES: Webhook-triggered ISR revalidation
Integrate Payload CMS¶
IF: Client needs visual editing and non-technical editor UX THEN: Evaluate Payload CMS — self-hosted, TypeScript-native, Next.js integrated
IF: Client needs admin panel out of the box THEN: Payload provides auto-generated admin UI from schema
INCLUDES: Self-hosted on client's EU infrastructure INCLUDES: TypeScript schema definitions with full type safety INCLUDES: Built-in access control and authentication INCLUDES: Rich text via Lexical editor (Payload default) or Tiptap plugin INCLUDES: Media uploads with image resizing COMPLIANCE: Data stays on EU servers — self-hosted, no SaaS dependency
Integrate External CMS (Strapi (FR), Payload (self-hosted) preferred. Sanity, Contentful (US) secondary only.)¶
IF: Client already uses an external CMS THEN: Integrate via API — don't migrate content. Add sovereignty warning if US-based CMS.
IF: Client's editorial team is trained on a specific CMS THEN: Keep existing CMS, build Next.js frontend. Recommend EU migration path if US-based.
INCLUDES: API client for content fetching INCLUDES: Webhook receiver for ISR revalidation INCLUDES: Content type mapping (external schema → internal types) CHECK: Verify CMS supports EU data residency CHECK: Verify CMS API rate limits meet traffic expectations CHECK: Verify CMS pricing at client's content volume
Structured Content Modeling¶
Content Type Design Principles¶
SCOPE_ITEM: Every content type has an explicit schema definition SCOPE_ITEM: Fields are strongly typed — no untyped JSONB blobs SCOPE_ITEM: References use foreign keys, not embedded IDs in JSON SCOPE_ITEM: Slug field on every publicly-routed content type SCOPE_ITEM: Timestamps — created_at, updated_at, published_at
PostgreSQL Schema Pattern¶
-- Content types table (one per type, or single table with discriminator)
CREATE TABLE articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
excerpt TEXT,
body JSONB NOT NULL, -- Tiptap JSON document
cover_image_id UUID REFERENCES media(id),
author_id UUID REFERENCES authors(id),
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','in_review','approved','scheduled','published','archived')),
published_at TIMESTAMPTZ,
scheduled_at TIMESTAMPTZ,
seo_title TEXT,
seo_description TEXT,
og_image_id UUID REFERENCES media(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Category and tag relations
CREATE TABLE article_categories (
article_id UUID REFERENCES articles(id) ON DELETE CASCADE,
category_id UUID REFERENCES categories(id) ON DELETE CASCADE,
PRIMARY KEY (article_id, category_id)
);
CREATE TABLE article_tags (
article_id UUID REFERENCES articles(id) ON DELETE CASCADE,
tag_id UUID REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (article_id, tag_id)
);
-- Revision history
CREATE TABLE article_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_id UUID REFERENCES articles(id) ON DELETE CASCADE,
body JSONB NOT NULL,
metadata JSONB NOT NULL, -- snapshot of title, excerpt, seo fields
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Content Type Variants¶
IF: All content types share similar structure (title, body, metadata)
THEN: Use single table with type discriminator and shared fields
IF: Content types have significantly different field sets THEN: Use separate tables per type with shared base columns
IF: Client needs composable page builder
THEN: Use JSONB block array — each block has type and data
Rich Text Editor — Tiptap / ProseMirror¶
Architecture¶
SCOPE_ITEM: Tiptap v2 as editor framework (headless, React integration) SCOPE_ITEM: ProseMirror schema defines allowed document structure SCOPE_ITEM: Content stored as Tiptap JSON (not HTML) in PostgreSQL JSONB SCOPE_ITEM: Render to HTML on read (server-side, cached)
Extension Stack (Standard)¶
SCOPE_ITEM: StarterKit — paragraphs, headings, bold, italic, lists, code blocks SCOPE_ITEM: Link — with URL validation and nofollow toggle SCOPE_ITEM: Image — inline images with alt text (required), caption SCOPE_ITEM: Placeholder — empty state guidance text SCOPE_ITEM: CharacterCount — real-time word and character count OPTIONAL: Table — with cell merge, column resize OPTIONAL: CodeBlockLowlight — syntax highlighting with language picker OPTIONAL: TaskList — interactive checklists OPTIONAL: YouTube / Embed — oEmbed integration OPTIONAL: Mention — @-mention authors or internal links
Slash Command Pattern¶
OPTIONAL: Type "/" to open block insertion menu
// Slash command suggestion plugin
const slashCommands = [
{ label: 'Heading 2', command: 'heading', attrs: { level: 2 } },
{ label: 'Image', command: 'image' },
{ label: 'Code Block', command: 'codeBlock' },
{ label: 'Quote', command: 'blockquote' },
{ label: 'Divider', command: 'horizontalRule' },
{ label: 'Table', command: 'table' },
];
Content Serialization¶
SCOPE_ITEM: Store as Tiptap JSON in database SCOPE_ITEM: Server-side HTML rendering for public pages SCOPE_ITEM: Sanitize HTML output (no script injection from stored JSON)
IF: Client needs Markdown export THEN: Add Tiptap Markdown extension for serialization
IF: Client needs content migration from existing system THEN: Build HTML → Tiptap JSON converter (one-time migration script)
Media Library¶
Upload Pipeline¶
SCOPE_ITEM: Direct upload to storage (S3-compatible or local volume) SCOPE_ITEM: Image processing on upload — generate responsive sizes SCOPE_ITEM: WebP and AVIF conversion for modern browsers SCOPE_ITEM: Original preserved for re-processing SCOPE_ITEM: EXIF data stripped (privacy — no GPS coordinates) SCOPE_ITEM: File size limits enforced (configurable per content type)
Image Optimization¶
Upload → Validate → Strip EXIF → Generate variants:
- thumbnail: 200x200 (cover)
- small: 640w
- medium: 1024w
- large: 1920w
- original: preserved
→ Convert to WebP + AVIF
→ Upload to CDN origin
→ Store metadata in PostgreSQL
SCOPE_ITEM: Next.js Image component with srcSet from generated sizes SCOPE_ITEM: Blur placeholder from thumbnail (base64 encoded) SCOPE_ITEM: Lazy loading with intersection observer
CDN Integration¶
IF: Client expects > 10,000 monthly visitors THEN: CDN for media delivery (bunny.net — EU-based, cost-effective)
INCLUDES: Pull zone from origin storage INCLUDES: Cache-Control headers with long TTL (immutable content-hashed URLs) INCLUDES: Automatic cache purge on media update or delete OPTIONAL: On-the-fly image resizing via CDN (bunny.net Image Processing)
Media Metadata Schema¶
CREATE TABLE media (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
file_size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
alt_text TEXT, -- required for images
caption TEXT,
credit TEXT,
license TEXT,
folder TEXT DEFAULT '/',
variants JSONB, -- { thumbnail: url, small: url, ... }
cdn_url TEXT,
uploaded_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Content Types and Relations¶
Relation Patterns¶
SCOPE_ITEM: One-to-many — article → author (single author per article) SCOPE_ITEM: Many-to-many — article ↔ categories, article ↔ tags SCOPE_ITEM: Self-referential — category → parent category (hierarchy) OPTIONAL: Polymorphic — "related content" can reference articles, pages, or products
API Design (Hono)¶
SCOPE_ITEM: RESTful CRUD per content type
SCOPE_ITEM: List endpoint with pagination, filtering, sorting
SCOPE_ITEM: Include relations via ?include=author,categories query param
SCOPE_ITEM: Content negotiation — JSON for API, rendered HTML for preview
GET /api/content/articles — list (paginated, filtered)
GET /api/content/articles/:slug — single by slug
POST /api/content/articles — create draft
PATCH /api/content/articles/:id — update
DELETE /api/content/articles/:id — soft delete (archive)
POST /api/content/articles/:id/publish — transition to published
POST /api/content/articles/:id/revert — rollback to revision
GET /api/content/articles/:id/revisions — version history
POST /api/media/upload — upload with processing
GET /api/media — media library listing
CHECK: All write endpoints require authentication and role-based authorization CHECK: Slug uniqueness enforced at database level CHECK: Published content served from ISR cache, not live database query