Website2026 · Essen

From Drupal 7 relict, straight to an edge-hosted CMS based website

A detour through a ready-made CMS, a custom admin panel, and 14 pages running without an active server.

Website for a customer's medical practice.

The website of a specialized consultant was running on Drupal 7 with a purchased theme. A fixed 1000-pixel width, no responsive design, a jQuery slideshow, and a Superfish dropdown menu. On a smartphone, you had to pinch, zoom, and swipe just to read. 17 URLs in total, 14 of which were genuine content pages detailing methods, processes, and use cases.

The content itself was good. An expert practicing since 2011 had spent years writing texts that genuinely explained things. But the shell around it was twelve years old, built on a CMS that no longer received security updates.

Step 1: Reading the old website as a spec

Before a single line of code was written, the existing site was consolidated into a single document. Every page text verbatim, the complete sitemap with its hierarchy, all hex values from the old stylesheet, font sizes, and a list of all images and where they were used.

The result was a brief of nearly 600 lines that made every future decision easily decidable. Later, when the question arose of what exact shade of blue the navigation used, the answer was already there. When the database seed was created, the texts were already available in a structured format, rather than having to be copy-pasted from a live Drupal instance.

This is the invisible work, and it carried the project through its entire runtime.

The Detour: A ready-made CMS first

Abandoned Path

A headless CMS on a managed host, behind a Cloudflare Worker acting as a cache proxy with KV, serving a React app on Cloudflare Pages. Cache invalidation via webhooks. There was a design spec, a data model with seven collections, and a meticulously planned migration strategy.

It didn't fail because of the technology, but because of the person who had to work with it.

The editorial team consists of exactly one person who is not a developer. To build the content for these pages, they needed building blocks like cards, timelines, and pricing tables. In a generic CMS interface, such structures end up in JSON fields. Image references, enums, and relationships between entries each require their own configuration steps. If you want to change the order of a paragraph, you end up editing an array.

Then there was the operational overhead: a running service, a managed database, a cache layer, and invalidation logic for a website with 14 pages that might change four times a year.

The CMS was scrapped, along with the extensions and the cache worker that had already been built. However, the written spec wasn't in vain—the data model it contained was transferred almost entirely unchanged into the new solution.

The Architecture that stayed

A pnpm monorepo with Turborepo and four workspaces:

apps/web

The public-facing site. Vite, React 19, React Router, Tailwind 4. Deployed on Cloudflare Pages.

apps/admin

The admin panel. Next.js 15, via OpenNext as a separate Worker on its own subdomain.

packages/db

Drizzle schema, migrations, and the seed. Target database is Cloudflare D1.

packages/shared

Zod schemas. The contract between all three.

The API for the public site consists of Pages Functions within the same deployment: /api/pages, /api/navigation, /api/settings, /api/theme, /api/images. They read from D1, which is bound directly to the Pages project. Images are stored in R2. There is no server that needs to be kept running, restarted, or patched.

packages/shared is the glue holding the construction together. The Zod schemas for pages, blocks, navigation, theme, and company data are defined exactly once. The frontend, admin panel, and seed script all import the same types. Define a new block type in one place, and TypeScript will immediately point out every location that doesn't handle it yet.

Content is Data, not HTML

A page in the database has a slug, a title, a meta description, and a JSON array of blocks. Eleven block types exist as a Zod discriminated union: heading, paragraph, quote, list, image, image-grid, separator, stats, timeline, cards, price-list.

The frontend renders them via a switch statement, while the admin panel provides a tailored input mask for each type. Rich text is passed through DOMPurify before hitting the DOM.

The practical payoff became evident during the redesign of three text-heavy pages. "About me" turned into key metrics plus a timeline and cards; "Use cases" became a card grid with icons; "Process & Pricing" turned into a timeline with a price list. The four block types built for this remained generic and have been available for every page ever since. Mobile-first was the mandate: a single column on narrow screens, expanding to more columns only after a breakpoint.

Three details that required the most work

Extracting page color from the photo

Every subpage features a large-format hero image, with the header and content overlaid as frosted glass panels. To prevent the page underneath from fading into pure white, a base color is calculated from the image: a pixel grid from the bottom third is mapped onto a 50-pixel-wide canvas, and the more saturated values are averaged. The result tints the gradient below the image. This is cached per image path in the module scope; otherwise, the color would briefly revert to the fallback and flicker during every client-side navigation.

Parallax that doesn't alter the resting state

The hero background moves at a different offset when scrolling, implemented using useScroll and useTransform from Framer Motion. The strict constraint was that the section at the top of the page had to look pixel-perfectly identical to how it looked before. That meant no scale on the image, as that changes the zoom level. Instead, a container vertically enlarged beyond the section boundaries with overflow: hidden was used, matching the maximum displacement of ±10%. prefers-reduced-motion naturally disables the movement.

An icon search that understands German

The card blocks have access to the complete Lucide set—1,474 icons. Their names and tags are in English, but the editor thinks in German. Initially, there was a manually maintained dictionary, which only worked as far as the terms it contained.

Now, a multilingual embedding model runs directly in the browser. The embeddings of all 1,474 icons are calculated at build time and shipped as a static file (about 2.3 MB for 1,474 × 384 float values). At runtime, only the user's input needs to be embedded and compared via cosine similarity. Until the model is fully loaded, the old dictionary fallback continues to handle searches. If the model fails to load, it simply stays that way.

The catch was the bundling: The Transformers package pulls in a native Node dependency, which has no place in Cloudflare's runtime and blows up the deployment size limit. Since the model runs exclusively on the client side via a dynamic import, the runtime never actually sees it. However, the bundler still had to be explicitly instructed not to package it.

Two environments, one command

Preview and Production each have their own D1 database and R2 bucket. Schema pushes, seeds, and deployments exist as separate scripts per environment, ensuring a test never touches live data.

The admin panel runs on its own subdomain rather than a path on the main site. This separation was introduced after the /admin proxy variant caused problems exactly where you'd expect: asset paths, session cookies, and redirects. A dedicated origin solves all three at once.

What mobile taught us

In September, feedback came in that the photos were displayed too large on smartphones, showing only a fraction of the image.

The initial reflex was to correct the image alignment globally. This worsened the desktop view without truly saving the mobile one, so it was reverted. A focal point that looks perfect on a 16:9 monitor simply looks wrong on a vertical phone screen, regardless of how cleverly you pick a single number.

The consequence is the current work-in-progress: dedicated mobile image settings alongside the existing desktop ones, plus a preview in the admin panel that exactly mirrors the design of the live site, allowing the crop and spacing to be adjusted directly. This is saved in the database, not in the seed, so no deployment overwrites the setting.

The fact that this requirement only surfaced after go-live is the most honest takeaway of the project: Responsive doesn't just mean the layout doesn't break. It means every image decision has to be made per format.

The Journey in Six Stages

  1. March 2026

    Audit & Inventory

    The old Drupal site is completely transcribed into a briefing document: texts, sitemap, colors, typography, assets.

  2. May 2026

    Headless CMS Migration

    Spec, data model, and migration plan are created; components are built. The editorial interface proves unusable for a single, non-technical editor.

  3. June 2026

    Back to D1, new visual language

    Custom admin panel instead of a third-party system. Professional photos of the client replace the old theme's stock images, displayed large-format with frosted glass overlays.

  4. July 2026

    Build and Polish

    Monorepo structure, four new content block types, parallax, previous/next navigation, semantic icon search. The vast majority of the 64 commits happen during these weeks.

  5. August 2026

    Admin on a dedicated Origin

    The proxy path under /admin is replaced by a dedicated subdomain; password handling for production is finalized.

  6. September 2026

    Mobile Image Control

    Client feedback regarding image cropping on smartphones. Ongoing work on separate mobile settings with a live preview inside the admin panel.

The Stack

Frontend

Vite, React 19, React Router, Tailwind 4, Framer Motion, Swiper

Admin

Next.js 15 via OpenNext, Drizzle ORM, Transformers.js

Platform

Cloudflare Pages, Pages Functions, D1, R2

Repo

pnpm Workspaces, Turborepo, TypeScript, Zod

Key Takeaways

The ready-made CMS was the more expensive solution—not in licensing costs, but in operations and in every single editorial task that followed. The question isn't how much a tool can do, but how much of it actually serves a one-person editorial team.

Every major change in this project began with a written spec and an implementation plan, both stored in the repo. For the parallax effect, the constraint that "nothing must change in the resting state" was written down before the first attempt even started. That made a second attempt unnecessary.

And the brief from the very beginning—that single document containing all texts, colors, and images of the old site—remained the single most useful artifact in the entire repository throughout the project's lifetime.

↑