Ein Umweg über ein fertiges CMS, ein selbstgebautes Redaktions-Panel und 14 Seiten, die ohne laufenden Server auskommen.

Die Website eines langjährigen Fachberaters lief auf Drupal 7 mit einem gekauften Theme. Feste 1000 Pixel Breite, kein Responsive Design, eine jQuery-Slideshow und ein Superfish-Dropdown im Menü. Auf dem Handy musste man zoomen und schieben. 17 URLs, davon 14 echte Inhaltsseiten über Methoden, Ablauf und Anwendungsgebiete.
Der Inhalt selbst war gut. Ein Experte, der seit 2011 tätig ist, hatte über Jahre Texte geschrieben, die etwas erklären. Nur das Gehäuse drumherum war zwölf Jahre alt und stand auf einem CMS, für das es keine Sicherheitsupdates mehr gibt.
Bevor eine Zeile Code entstand, wurde die bestehende Seite zu einem einzigen Dokument. Jeder Seitentext im Wortlaut, die komplette Sitemap mit Hierarchie, alle Hex-Werte aus dem alten Stylesheet, die Schriftgrößen, die Liste aller Bilder mit ihrer Verwendung.
Das Ergebnis war ein Brief von knapp 600 Zeilen, der jede spätere Entscheidung entscheidbar machte. Als später die Frage aufkam, welchen Blauton die Navigation eigentlich hatte, stand die Antwort da. Als der Seed für die Datenbank entstand, waren die Texte schon in strukturierter Form vorhanden, statt aus einer laufenden Drupal-Instanz kopiert werden zu müssen.
Das ist die Arbeit, die niemand sieht, und sie hat das Projekt über die gesamte Laufzeit getragen.
Verworfener Pfad
Headless CMS auf einem gemanagten Host, davor ein Cloudflare Worker als Cache-Proxy mit KV, dahinter die React-App auf Cloudflare Pages. Cache-Invalidierung über Webhooks. Es gab eine Design-Spec, ein Datenmodell mit sieben Collections und einen durchgeplanten Migrationsplan.
Gescheitert ist es nicht an der Technik, sondern an der Person, die damit arbeiten sollte.
Die Redaktion besteht aus genau einem Menschen, der kein Entwickler ist. Für den Inhalt dieser Seiten brauchte er Bausteine wie Karten, Zeitleisten und Preislisten. In einer generischen CMS-Oberfläche landen solche Strukturen in JSON-Feldern, und Bildreferenzen, Enums und Verknüpfungen zwischen Einträgen bedeuten jeweils einen eigenen Konfigurationsschritt. Wer eine Absatzreihenfolge ändern will, editiert dann ein Array.
Dazu kam der Betrieb: ein laufender Dienst, eine verwaltete Datenbank, ein Cache-Layer und eine Invalidierungslogik für eine Website mit 14 Seiten, die sich vielleicht viermal im Jahr ändert.
Das CMS flog raus, inklusive der bereits gebauten Extensions und des Cache-Workers. Die geschriebene Spec war trotzdem nicht umsonst, denn das Datenmodell darin ist fast unverändert in die neue Lösung übernommen worden.
Ein pnpm-Monorepo mit Turborepo und vier Workspaces:
apps/web | Die öffentliche Seite. Vite, React 19, React Router, Tailwind 4. Deployed auf Cloudflare Pages. |
apps/admin | Das Redaktions-Panel. Next.js 15, via OpenNext als eigener Worker auf einer eigenen Subdomain. |
packages/db | Drizzle-Schema, Migrationen und der Seed. Zieldatenbank ist Cloudflare D1. |
packages/shared | Zod-Schemas. Der Vertrag zwischen allen dreien. |
Die API der öffentlichen Seite sind Pages Functions im selben Deployment: /api/pages, /api/navigation, /api/settings, /api/theme, /api/images. Sie lesen aus D1, das direkt am Pages-Projekt gebunden ist. Bilder liegen in R2. Es gibt keinen Server, der laufen, neustarten oder gepatcht werden muss.
packages/shared ist das Stück, das die Konstruktion zusammenhält. Die Zod-Schemas für Seiten, Blöcke, Navigation, Theme und Unternehmensdaten stehen genau einmal. Frontend, Admin-Panel und Seed-Skript importieren dieselben Typen. Ein neuer Block-Typ wird an einer Stelle definiert, und TypeScript zeigt danach jede Stelle, die ihn noch nicht behandelt.
Eine Seite in der Datenbank hat einen Slug, einen Titel, eine Meta-Description und ein JSON-Array von Blöcken. Elf Block-Typen als Zod-Discriminated-Union: heading, paragraph, quote, list, image, image-grid, separator, stats, timeline, cards, price-list.
Das Frontend rendert sie über ein switch, das Admin-Panel bietet pro Typ eine passende Eingabemaske. Fließtext geht durch DOMPurify, bevor er ins DOM kommt.
Der praktische Gewinn zeigte sich beim Redesign dreier textlastiger Seiten. „Über mich“ wurde zu Kennzahlen plus Zeitleiste plus Karten, „Anwendungsgebiete“ zu einem Karten-Raster mit Icons, „Ablauf & Kosten“ zu einer Zeitleiste mit Preisliste. Die vier dafür gebauten Block-Typen sind generisch geblieben und stehen seitdem für jede Seite zur Verfügung. Mobile-first war Vorgabe: eine Spalte auf schmalen Screens, mehr Spalten erst ab Breakpoint.
Jede Unterseite hat ein großformatiges Hero-Bild, über dem Header und Content als Milchglasflächen liegen. Damit die Seite darunter nicht ins Weiße kippt, wird aus dem Bild eine Farbe berechnet: ein Pixelraster aus dem unteren Drittel auf einem 50 Pixel breiten Canvas, gesättigtere Werte gemittelt. Das Ergebnis färbt den Verlauf unterhalb des Bildes. Gecacht wird pro Bildpfad im Modul-Scope, sonst springt die Farbe bei jeder Client-Navigation kurz auf den Fallback zurück und flackert.
Der Hero-Hintergrund bewegt sich beim Scrollen versetzt, umgesetzt mit useScroll und useTransform aus Framer Motion. Die harte Nebenbedingung war, dass die Sektion oben auf der Seite pixelgenau aussieht wie vorher. Also kein scale auf das Bild, weil das die Zoomstufe ändert, sondern ein vertikal über die Sektionsgrenzen hinaus vergrößerter Container mit overflow: hidden, passend zur maximalen Verschiebung von ±10 %. prefers-reduced-motion schaltet die Bewegung ab.
Für die Karten-Blöcke steht das komplette Lucide-Set zur Verfügung, 1.474 Icons. Deren Namen und Tags sind englisch, die Redaktion denkt auf Deutsch. Erst gab es ein handgepflegtes Wörterbuch, das genau so weit reichte wie die Begriffe darin.
Jetzt läuft ein mehrsprachiges Embedding-Modell im Browser. Die Embeddings aller 1.474 Icons werden zur Build-Zeit berechnet und als statische Datei mitgeliefert, rund 2,3 MB für 1.474 × 384 Float-Werte. Zur Laufzeit muss nur noch die Eingabe eingebettet und per Kosinus-Ähnlichkeit verglichen werden. Bis das Modell geladen ist, sucht die alte Wörterbuch-Variante weiter, und wenn es nie lädt, bleibt sie einfach die Suche.
Der Haken lag im Bundling: Das Transformers-Paket zieht eine native Node-Abhängigkeit nach, die auf Cloudflares Runtime nichts zu suchen hat und das Deployment-Größenlimit sprengt. Das Modell läuft ausschließlich clientseitig über einen dynamischen Import, die Runtime sieht es also nie. Der Bundler musste trotzdem explizit davon abgehalten werden, es einzupacken.
Preview und Produktion haben jeweils eine eigene D1-Datenbank und einen eigenen R2-Bucket. Schema-Push, Seed und Deployment existieren als getrennte Skripte pro Umgebung, sodass ein Test nie auf die Live-Daten trifft.
Das Admin-Panel läuft auf einer eigenen Subdomain statt unter einem Pfad der Hauptseite. Diese Trennung kam nachträglich, nachdem die Variante über einen /admin-Proxy an genau den Stellen Probleme machte, die man erwarten würde: Asset-Pfade, Session-Cookies, Redirects. Eine eigene Origin löst alle drei auf einmal.
Im September kam die Rückmeldung, dass die Fotos auf dem Smartphone zu groß dargestellt werden und man nur einen Ausschnitt sieht.
Der erste Reflex war, die Bildausrichtung global zu korrigieren. Das machte die Desktop-Ansicht schlechter, ohne die mobile wirklich zu retten, und wurde zurückgenommen. Der Bildausschnitt, der auf einem 16:9-Monitor richtig sitzt, sitzt auf einem hochkanten Handydisplay falsch, und zwar unabhängig davon, wie gut man die eine Zahl wählt.
Die Konsequenz ist die aktuelle Baustelle: eigene mobile Bildeinstellungen neben den bestehenden für Desktop, plus eine Vorschau im Admin-Panel, die exakt das Design der Live-Seite zeigt und in der sich Ausschnitt und Abstände direkt schieben lassen. Gespeichert wird das in der Datenbank, nicht im Seed, damit kein Deployment die Einstellung überschreibt.
Dass diese Anforderung erst nach dem Livegang auftauchte, ist die ehrlichste Erkenntnis des Projekts: Responsive heißt nicht, dass das Layout nicht bricht. Es heißt, dass jede Bildentscheidung pro Format getroffen werden muss.
März 2026
Die alte Drupal-Seite wird vollständig in ein Briefing-Dokument überführt: Texte, Sitemap, Farben, Typografie, Assets.
Mai 2026
Spec, Datenmodell und Migrationsplan entstehen, Teile werden gebaut. Die Redaktionsoberfläche erweist sich für einen einzelnen, nicht-technischen Redakteur als unbrauchbar.
Juni 2026
Eigenes Admin-Panel statt Fremdsystem. Professionelle Fotos des Kunden ersetzen die Stock-Bilder des alten Themes, großformatig mit Milchglas-Overlay.
Juli 2026
Monorepo-Struktur, vier neue Content-Block-Typen, Parallax, Vorherige/Nächste-Navigation, semantische Icon-Suche. Der Großteil der 64 Commits fällt in diese Wochen.
August 2026
Der Proxy-Pfad unter /admin wird durch eine eigene Subdomain ersetzt, Passwort-Handling für Produktion geklärt.
September 2026
Kundenfeedback zu Bildausschnitten auf dem Smartphone. Laufende Arbeit an getrennten mobilen Einstellungen mit Live-Vorschau im Panel.
Vite, React 19, React Router, Tailwind 4, Framer Motion, Swiper
Next.js 15 über OpenNext, Drizzle ORM, Transformers.js
Cloudflare Pages, Pages Functions, D1, R2
pnpm Workspaces, Turborepo, TypeScript, Zod
Das fertige CMS war die teurere Lösung. Nicht in Lizenzkosten, sondern in Betrieb und in jedem einzelnen Redaktionsvorgang danach. Die Frage ist nicht, wie viel ein Werkzeug kann, sondern wie viel davon bei einer Redaktion aus einer Person tatsächlich ankommt.
Jede größere Änderung in diesem Projekt begann mit einer geschriebenen Spec und einem Umsetzungsplan, beide im Repo. Für den Parallax-Effekt stand die Nebenbedingung, dass sich im Ruhezustand nichts verändern darf, schriftlich fest, bevor der erste Versuch startete. Das hat den zweiten Versuch überflüssig gemacht.
Und der Brief vom Anfang, dieses Dokument mit allen Texten, Farben und Bildern der alten Seite, war über die gesamte Laufzeit das nützlichste Artefakt im ganzen Repository.