Skip to content

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