CMS Chronicle·March 2026·3 min read

CMS Chronicle #06: Block Editor and Docker Block Editor & Docker

One image alignment bug that became a full block editor system, a broken upload architecture fixed three times, and two working deployments — local Docker and Fly.io.

It always starts with "just a quick thing"

An image in TipTap was floating left in the editor but not on the site. "We'll fix it quickly."

Six hours later: a full block system, an image carousel, a rebuilt upload architecture, and our first Dockerfile.

The block system

The core idea: reusable content blocks — comparison, notice, carousel — defined in cms.config.ts and inserted into articles with [block:slug]. Each block type has its own field schema; the editor shows only the relevant fields based on a blockType discriminator. Same pattern as Drupal paragraphs, implemented in 30 lines of TypeScript.

Blocks render as 🧩 chips in TipTap via a custom atom Node extension. Double-click to replace, autocomplete search, Enter to select. An image carousel block with drag-and-drop gallery editor was the first one we built to test the whole system end to end.

Two bugs, both invisible in TypeScript

After building the whole system, [block:slug] showed nothing on the site.

Bug 1: The regex didn't match because TipTap had escaped [block:slug] to \[block:slug\] in storage. Fix: normalise before regex.

Bug 2: getBlocksFromContent() returns full Document objects, but BlockRenderer was reading data.blockType where the actual path is doc.data.blockType. Fix: unpack .data before passing to the renderer.

Both were hidden behind as unknown as casts. TypeScript can't save you from shape mismatches you cast away.

The upload architecture broke three times

Two separate Next.js apps can't share a public/ folder. We went through the obvious wrong answers — changing UPLOAD_DIR, adding a symlink — before landing on the right one: the admin serves uploads dynamically via GET /api/uploads/[...path] reading from UPLOAD_DIR, with a Next.js rewrite mapping /uploads/* to that route. No symlinks, no copying, works everywhere.

TipTap round-trips

Image alignment also lost itself on reload. tiptap-markdown serialises float:left into the title attribute but doesn't parse it back. Fix: parse.updateDOM() in extension storage — traverse <img> elements before ProseMirror parses the DOM and restore data-align and style.width from the title.

Writing a custom serializer is not enough. You must also write the parser.

Docker: two containers, one command

The cms-admin Dockerfile is multi-stage: Alpine with native build tools for better-sqlite3, a builder stage, a slim runner. export const dynamic = 'force-dynamic' stops Next.js from pre-rendering at build time with a local path that doesn't exist inside the container.

The webhouse-site gets its own Dockerfile too, with the build context set to the parent directory so both cms-engine/packages/cms and webhouse-site/ are available in the same build. CONTENT_DIR as an env var lets the CMS resolve the right content path at runtime instead of relative to the compiled file.

docker compose up from cms-engine/ starts both: the site on port 4009, the admin on 4010. A single mounted volume points both containers at the same content and uploads directory on the host. Edit a JSON file locally, reload the browser — instant.

Fly.io: same setup, Stockholm

The Fly.io deploy takes the same idea one step further. A combined Dockerfile builds both apps in one image. A shell start script seeds content from the image to a persistent Fly volume on first boot, then starts both Next.js processes bound to 0.0.0.0.

One fly deploy command. One machine in arn (Stockholm). One persistent volume at /data shared between both processes. The site is on port 443, the admin on 3010 — both live at webhouse-cms.fly.dev with zero configuration beyond the fly.toml.