
Contao Headless Datenstruktur & Redakteurs-Erlebnis

Der Paradigmenwechsel im Seitenbaum
Der wohl größte Kritikpunkt an entkoppelten Architekturen ist oft das schlechte Redakteurs-Erlebnis (Editor Experience). Genau hier setzt eine durchdachte Contao Headless Datenstruktur an, um das Beste aus beiden Welten zu vereinen. In reinen API-first Systemen (wie Contentful oder Strapi) verwalten Redakteure oft nur noch fragmentierte Daten-Snippets ohne räumlichen Bezug zur späteren Website.
Genau hier spielt Contao seine größte Stärke aus: Wir behalten den bewährten, hierarchischen Seitenbaum und die Artikel-Struktur bei. Das Redaktionsteam arbeitet in seiner gewohnten Umgebung. Wir müssen die Contao Headless Datenstruktur jedoch konzeptionell neu bewerten. Im dekoppelten Betrieb verliert der Contao-Seitenbaum seine Funktion als physischer HTML-Generator. Er wandelt sich stattdessen zu einer semantischen Routing- und Berechtigungsdatenbank.
Im dekoppelten Betrieb verliert der Contao-Seitenbaum seine Funktion als physischer HTML-Generator. Er wandelt sich stattdessen zu einer semantischen Routing- und Berechtigungsdatenbank. Ein "Startpunkt einer Webseite" (Website Root) generiert kein <head>-Markup mehr, sondern definiert die Basis-URL, die Spracheinstellung und globale Metadaten für unseren JSON-Payload. Die Seiten und Artikel darin bilden die exakte API-Routenstruktur ab, die unser Next.js-Frontend später über Catch-All-Routen (app/[...slug]/page.tsx) dynamisch konsumiert.
Praxis & Code: Den Headless-Startpunkt konfigurieren
Wir legen nun das Fundament für unsere API-Routen im Contao-Backend an. Stelle sicher, dass du im Contao-Backend (http://localhost:8080/contao) eingeloggt bist.
Navigiere links in der Navigation zu Seitenstruktur.
Klicke auf Neu, um eine neue Seite anzulegen.
Konfiguriere den Startpunkt einer Webseite zwingend mit folgenden Parametern für den Headless-Betrieb:
Seitentyp: Startpunkt einer Webseite
Seitenname: Haupt-API (oder der Name deines Projekts)
Sprache:
de(oderen- wichtig für den späteren Sprach-Umschalter im Frontend)Domainname: Lass dieses Feld für die lokale Entwicklung leer.
Best Practice für später: In Contao 5.3+ nutzen wir die
.env.localVariableDNS_MAPPING, um Domains zwischen lokalen und Live-Systemen nahtlos umzuschreiben, ohne die Datenbank anzufassen (z.B.DNS_MAPPING='{"lokal.dev": "live-domain.de"}').
Seitenlayout: Wähle hier vorerst ein leeres Layout aus (falls Contao danach verlangt). Im Headless-Betrieb ignorieren wir das Frontend-Layout weitestgehend, da Next.js das Rendering übernimmt.
Die Test-Struktur aufbauen: Lege unterhalb dieses Startpunkts nun drei reguläre Seiten an:
Startseite(Alias:indexoderhome)Über uns(Alias:ueber-uns)Leistungen(Alias:leistungen)
Diese Aliase sind entscheidend. Wenn dein Next.js-Frontend später die Route /ueber-uns aufruft, wird unser PageController (den wir in Teil 3 vorbereitet haben) genau in diesem Seitenbaum nach dem Alias suchen und den verknüpften JSON-Payload zurückliefern.

Das Redakteurs-Erlebnis (DX) härten – DCA Anpassungen
Auf der untersten Ebene unseres Seitenbaums arbeiten die Redakteure mit Artikeln und Inhaltselementen (Texte, Bilder, Galerien). In einem klassischen Contao-Setup sind diese Formulare vollgepackt mit Layout-Einstellungen: Redakteure können Abstände definieren (Margin/Padding), CSS-Klassen vergeben oder individuelle .html5-Templates aus einem Dropdown-Menü auswählen.
In einer kompromisslosen Headless-Architektur mit Next.js sind diese Felder nicht nur nutzlos, sie sind gefährlich. Wenn das Frontend-Team ein pixelperfektes, komponentenasiertes Design-System in Tailwind CSS gebaut hat, darf ein Redakteur das Layout nicht durch die Eingabe von willkürlichen CSS-Klassen oder Margins im CMS zerstören.
Die Regel lautet: Das CMS liefert ausschließlich den puren Inhalt (Data). Das Frontend (Next.js) bestimmt zu 100 % das Aussehen (Presentation).
Damit die Redakteure nicht durch bedeutungslose Layout-Felder verwirrt werden, passen wir das Data Container Array (DCA) von Contao an. Wir modifizieren die Backend-Formulare updatesicher per Code und entfernen alle Felder, die im Headless-Betrieb keine Relevanz mehr haben (wie space für Abstände oder customTpl für Template-Auswahlen).
Praxis & Code: Die tl_content.php bereinigen
Ab Contao 4.9+ nutzen wir für solche Anpassungen den eleganten PaletteManipulator. Erstelle in deinem Backend-Verzeichnis den Ordner contao/dca/ (falls er noch nicht existiert) und lege darin die Datei tl_content.php an. Diese Datei überschreibt updatesicher die Formular-Darstellung der Inhaltselemente.
Datei: workspace/contao-headless-masterclass/contao-backend/contao/dca/tl_content.php
1<?php
2
3use Contao\CoreBundle\DataContainer\PaletteManipulator;
4
5// 1. Wir initialisieren den Manipulator, um irrelevante Felder zu entfernen
6$paletteManipulator = PaletteManipulator::create()
7 // Das Feld "Abstand davor und dahinter" (space) entfernen
8 ->removeField('space', 'expert_legend')
9 // Das Feld "Individuelles Template" (customTpl) entfernen,
10 // da wir keine PHP/HTML5 Templates mehr rendern!
11 ->removeField('customTpl', 'template_legend')
12 // Optionale Layout-Einstellungen wie Ausrichtung (align) entfernen
13 ->removeField('align', 'image_legend');
14
15// 2. Wir wenden diese Bereinigung auf die wichtigsten Standard-Elemente an
16$paletteManipulator->applyToPalette('text', 'tl_content');
17$paletteManipulator->applyToPalette('image', 'tl_content');
18$paletteManipulator->applyToPalette('headline', 'tl_content');
19$paletteManipulator->applyToPalette('html', 'tl_content');
20$paletteManipulator->applyToPalette('list', 'tl_content');
21
22// 3. (Optional) Globale DCA-Eingriffe:
23// Wir machen das Feld "cssID" (CSS-ID/Klasse) standardmäßig unsichtbar,
24// behalten es aber in der Datenbank, falls wir die ID für Anker-Links in Next.js brauchen.
25$GLOBALS['TL_DCA']['tl_content']['fields']['cssID']['eval']['tl_class'] = 'hidden';Um diese tiefgreifende Änderung im Backend sichtbar zu machen, musst du den Symfony-Anwendungs-Cache über das Terminal leeren:
docker compose exec php vendor/bin/contao-console cache:clearDas Ergebnis für den Redakteur: Wenn sich dein Redaktionsteam nun im Backend einloggt und ein neues Text- oder Bildelement anlegt, ist das Formular radikal verschlankt. Keine verwirrenden "Experten-Einstellungen" zu Abständen oder Templates mehr. Sie sehen nur noch das, was wirklich zählt: Die Überschrift, den Text-Editor und die Bild-Auswahl. Das steigert die Akzeptanz des Headless-Systems beim Kunden enorm.

Die Contao Headless Datenstruktur mit Custom Elements erweitern
Standard-Elemente wie Text, Bilder oder Überschriften bilden das Grundgerüst jeder Seite. Bei Enterprise-Projekten stößt du damit jedoch schnell an redaktionelle Grenzen. Stell dir vor, du möchtest auf der "Über uns"-Seite ein Raster von Team-Mitgliedern darstellen.
In der klassischen Web-Welt würdest du vielleicht einen Text-Editor missbrauchen, ein Bild einfügen und hoffen, dass der Redakteur die CSS-Klassen für das Layout richtig setzt. In einer Headless-Architektur ist das ein absolutes No-Go. Wir benötigen strikt typisierte, isolierte Daten. Wir wollen dem Next.js-Frontend exakt folgendes JSON liefern: {"type": "team_member", "name": "Max Mustermann", "role": "CEO", "image": "..."}.
Um dies zu erreichen, erstellen wir ein Custom Element (ein eigenes Inhaltselement). Wir legen dafür die Datenbankfelder an, definieren die Eingabemaske im Backend (DCA) und bringen unserem Serializer bei, wie er diese neuen Daten für die API übersetzen soll.
Praxis & Code: Ein "Team Member" Element aufbauen
Wir erweitern zunächst die Datenbankstruktur der Tabelle tl_content um unsere neuen Felder und definieren gleichzeitig, wie die Eingabemaske für den Redakteur aussieht.
Füge folgenden Code in deine bestehende tl_content.php ein:
Datei: workspace/contao-headless-masterclass/contao-backend/contao/dca/tl_content.php (Ergänzung)
1<?php
2
3// ... bestehender Code (PaletteManipulator aus Abschnitt 2) ...
4
5// 1. Neue Datenbank-Felder im DCA registrieren
6$GLOBALS['TL_DCA']['tl_content']['fields']['team_name'] = [
7 'label' => ['Name des Mitarbeiters', 'Geben Sie den Vor- und Nachnamen ein.'],
8 'inputType' => 'text',
9 'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'],
10 'sql' => "varchar(255) NOT NULL default ''"
11];
12
13$GLOBALS['TL_DCA']['tl_content']['fields']['team_role'] = [
14 'label' => ['Position / Rolle', 'Geben Sie die Position im Unternehmen ein.'],
15 'inputType' => 'text',
16 'eval' => ['mandatory' => true, 'maxlength' => 255, 'tl_class' => 'w50'],
17 'sql' => "varchar(255) NOT NULL default ''"
18];
19
20// 2. Die neue Palette (Eingabemaske) für das Element 'team_member' definieren
21// Wir nutzen 'type' (Dropdown zur Auswahl des Elements), unsere neuen Felder und das native 'singleSRC' für das Bild
22$GLOBALS['TL_DCA']['tl_content']['palettes']['team_member'] = '
23 {type_legend},type;
24 {team_legend},team_name,team_role,singleSRC;
25 {invisible_legend:hide},invisible,start,stop
26';Führe nun das Datenbank-Update im Terminal aus, damit Contao die neuen Spalten (team_name, team_role) in der Datenbank anlegt:
docker compose exec php vendor/bin/contao-console contao:migrateDer Serializer-Schritt: Der Redakteur kann dieses Element nun im Backend anlegen und befüllen. Damit diese Daten aber nicht im Contao-Backend versauern, sondern über unsere API an Next.js geschickt werden, müssen wir unseren ContentSerializer (aus Teil 3) erweitern.
Öffne die Datei ContentSerializer.php und füge den case für unser neues Element im switch-Block hinzu:
Datei: workspace/contao-backend/src/Serializer/ContentSerializer.php (Ergänzung)
1// ... bestehender Code ...
2
3switch ($element->type) {
4 // ... text, image, headline ...
5
6 case 'team_member':
7 $data['team'] = [
8 'name' => $element->team_name,
9 'role' => $element->team_role,
10 ];
11
12 // Wir nutzen die resolveImage-Methode aus Teil 3, um die Bild-UUID in Pfade aufzulösen
13 if ($element->singleSRC) {
14 $data['image'] = $this->resolveImage($element);
15 }
16 break;
17
18 default:
19 return null;
20}
21
22// ...Leere abschließend den Cache (vendor/bin/contao-console cache:clear).
Das Ergebnis: Die Trennung von Inhalt und Design ist nun perfektioniert. Der Redakteur wird gezwungen, Name und Position in getrennte Felder einzutragen. Es gibt keine Möglichkeit für ihn, das Design durch falsch platziertes HTML im Text-Editor zu zerstören. Das Next.js-Frontend erhält einen fehlerfreien, typsicheren JSON-Datensatz und rendert daraus die vorgebaute <TeamCard /> React-Komponente.

Die Königsdisziplin – Formulare Headless verarbeiten
Wenn du eine reine Lese-API (GET-Requests) für Texte und Bilder aufbaust, bewegst du dich in einer sehr sicheren, gut kontrollierbaren Umgebung. Sobald das Next.js-Frontend jedoch Daten vom User an das Contao-Backend senden soll (POST-Requests), betreten wir die Königsdisziplin der Headless-Architektur: Formulare.
In der monolithischen Welt generiert der Contao Formulargenerator das HTML-Markup, injiziert unsichtbare CSRF-Tokens (Cross-Site Request Forgery) zur Sicherheit und erwartet beim Klick auf "Senden" einen synchronen Page-Reload, der die Daten verarbeitet.
Im Headless-Betrieb funktioniert das nicht. Unser Next.js-Frontend baut das Formular selbst auf (z. B. mit React Hook Form), verwaltet den Zustand clientseitig und sendet die Daten asynchron via JavaScript (fetch API) ab. Das Backend muss diese rohen JSON-Daten empfangen, validieren und eine maschinenlesbare JSON-Antwort (Erfolg oder Fehler) zurückliefern, damit Next.js dem User entsprechendes Feedback (z. B. einen grünen Haken) anzeigen kann.
Anstatt zu versuchen, den nativen Contao-Formulargenerator über komplexe Umwege Headless-kompatibel zu verbiegen, wählen wir den sauberen, modernen API-First-Ansatz. Wir schreiben einen eigenen Symfony-Controller, der als dedizierter Endpoint für unser Frontend-Kontaktformular dient.
Praxis & Code: Den Formular-Endpoint (POST) aufbauen
Wir erstellen einen Controller, der ausschließlich auf POST-Requests hört. Er nimmt das JSON-Paket aus Next.js entgegen, validiert die Eingaben (z. B. ob die E-Mail-Adresse korrekt formatiert ist) und versendet anschließend die Daten via Symfony Mailer.
Erstelle die Datei ContactFormController.php in deinem API-Verzeichnis:
Datei: workspace/contao-headless-masterclass/contao-backend/src/Controller/Api/ContactFormController.php
1<?php
2
3namespace App\Controller\Api;
4
5use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
6use Symfony\Component\HttpFoundation\JsonResponse;
7use Symfony\Component\HttpFoundation\Request;
8use Symfony\Component\Routing\Annotation\Route;
9use Symfony\Component\Mailer\MailerInterface;
10use Symfony\Component\Mime\Email;
11
12// Wichtig: Wir beschränken diese Route strikt auf die HTTP-Methode 'POST'
13#[Route('/api/v1/form/contact', name: 'api_form_contact', methods: ['POST'], defaults: ['_scope' => 'frontend'])]
14class ContactFormController extends AbstractController
15{
16 // Wir injizieren den Symfony Mailer, um die Nachricht zu versenden
17 public function __construct(private MailerInterface $mailer)
18 {
19 }
20
21 public function __invoke(Request $request): JsonResponse
22 {
23 // 1. JSON-Payload aus dem Request-Body extrahieren
24 $payload = json_decode($request->getContent(), true);
25
26 // 2. Serverseitige Sicherheits-Validierung (Vertraue niemals dem Frontend!)
27 if (empty($payload['email']) || !filter_var($payload['email'], FILTER_VALIDATE_EMAIL)) {
28 return new JsonResponse(['error' => 'Bitte geben Sie eine gültige E-Mail-Adresse ein.'], 400);
29 }
30
31 if (empty($payload['message']) || strlen($payload['message']) < 10) {
32 return new JsonResponse(['error' => 'Ihre Nachricht ist zu kurz.'], 400);
33 }
34
35 // 3. E-Mail generieren und versenden
36 // Hinweis: Konfiguriere deinen SMTP-Server in der .env.local (MAILER_DSN)
37 try {
38 $email = (new Email())
39 ->from('system@deine-domain.de')
40 ->replyTo($payload['email'])
41 ->to('info@deine-domain.de')
42 ->subject('Neue Headless-Kontaktanfrage')
43 ->text("Absender: {$payload['email']}\n\nNachricht:\n{$payload['message']}");
44
45 $this->mailer->send($email);
46
47 } catch (\Exception $e) {
48 // Logging wäre hier ideal, für den User geben wir einen generischen Fehler aus
49 return new JsonResponse(['error' => 'Die Nachricht konnte nicht gesendet werden.'], 500);
50 }
51
52 // 4. Erfolgsmeldung an Next.js zurückliefern
53 return new JsonResponse([
54 'success' => true,
55 'message' => 'Vielen Dank! Ihre Anfrage wurde erfolgreich übermittelt.'
56 ], 200);
57 }
58}Nachdem du die Datei gespeichert hast, musst du den Cache leeren (vendor/bin/contao-console cache:clear), damit Symfony die neue POST-Route registriert.
Wie interagiert Next.js nun damit? Dein Frontend-Entwickler kann nun einfach folgenden JavaScript-Code nutzen, um das Formular sicher an Contao zu senden:
1// Beispiel für den Frontend-Call in Next.js
2const response = await fetch('http://localhost:8080/api/v1/form/contact', {
3 method: 'POST',
4 headers: { 'Content-Type': 'application/json' },
5 body: JSON.stringify({
6 email: 'user@example.com',
7 message: 'Hallo, ich interessiere mich für die Headless Architektur!'
8 })
9});
10
11const data = await response.json();
12// data.success oder data.error verarbeitenDurch diese strikte Trennung haben wir die Kontrolle behalten. Das Frontend kümmert sich um UX (Lade-Animationen, rotes Leuchten bei Fehleingaben), und das Contao-Backend agiert als robuster, sicherer Gatekeeper, der Daten verifiziert und Systemaktionen (E-Mail-Versand, Datenbankeintrag) ausführt.
Wir haben in diesem Teil den kompletten Bogen für das Redakteurs-Erlebnis und die Datenstruktur gespannt – vom Seitenbaum über Custom Elements (DCA) bis hin zu sicheren Formularen.

Fazit – Das Beste aus zwei Welten
Mit dem Abschluss dieser Phase haben wir den vielleicht größten Architektur-Konflikt der Headless-Entwicklung gelöst: Den Kompromiss zwischen Entwickler-Freiheit und Redakteurs-Komfort.
Ein Headless CMS muss nicht bedeuten, dass das Marketing-Team in abstrakten, unstrukturierten Datenwüsten arbeiten muss. Indem wir den hierarchischen Seitenbaum von Contao als intelligentes Routing-System beibehalten haben, fühlt sich die Redaktion sofort zu Hause. Durch unsere präzisen DCA-Anpassungen (PaletteManipulator) haben wir das Backend gleichzeitig von gefährlichem Design-Ballast befreit. Die Erstellung maßgeschneiderter Custom Elements zwingt zur sauberen Trennung von Inhalt (Data) und Darstellung (Presentation), während unser dedizierter Formular-Endpoint (POST-Request) maximale Sicherheit für User-Eingaben garantiert.
Wir haben das Contao Backend in eine hochgradig spezialisierte, typsichere Daten-Schmiede verwandelt, die exakt den strukturierten JSON-Payload liefert, den unser Next.js-Frontend für den perfekten Komponenten-Aufbau benötigt.
Contao Headless Masterclass: High-Performance mit Next.js Pillar
Contao Headless Architektur: Das Konzept im Detail verstehen
Projektstruktur & Entwicklungsumgebung für Contao und Next.js
Contao Headless Basis-Setup: Installation & Bildkonfiguration
Contao Headless Datenstruktur & Redakteurs-Erlebnis
Häufig gestellte Fragen (FAQ)
Das wäre ein architektonisches Anti-Pattern (oft als "Decoupled", aber nicht als "echtes Headless" bezeichnet). Wenn die API fertiges HTML liefert, verlierst du die größte Stärke von React/Next.js: Die komponentenbasierte Architektur. Das Frontend könnte auf Datenänderungen nicht mehr interaktiv reagieren, und die Pflege von Tailwind CSS-Klassen wäre auf zwei Systeme verteilt. Die API darf ausschließlich pure Daten (JSON) liefern.
Next.js nutzt dafür sogenannte Catch-All Routes (z. B. eine Datei namens app/[...slug]/page.tsx). Wenn ein User die URL /unternehmen/team aufruft, fängt Next.js diesen Pfad ab und sendet ihn an unsere Contao-API. Contao sucht in der Datenbank nach diesem Alias, generiert den Payload für die Seite "Team" und Next.js rendert die entsprechenden React-Komponenten.
Das kommt auf den Anwendungsfall an. Du kannst den Formulargenerator im Backend weiterhin nutzen, um Redakteuren die Möglichkeit zu geben, Felder per Drag-and-Drop zusammenzustellen. Dein Serializer müsste dann jedoch die Konfiguration jedes einzelnen Feldes (Typ, Validierungsregeln, Pflichtfeld) in JSON übersetzen, und Next.js müsste dynamisch das React-Formular daraus bauen. Für komplexe, maßgeschneiderte Formulare (wie Kontaktanfragen oder Registrierungen) ist ein fest programmierter POST-Endpoint im Controller oft deutlich performanter, sicherer und weniger fehleranfällig.
Contao speichert interne Links als Insert-Tags (z. B. {{link_url::12}}). Dein Content-Serializer oder ein spezifischer Twig-Filter muss diese Insert-Tags auflösen, bevor das JSON an Next.js gesendet wird. So wird aus dem Tag der echte, lesbare Pfad /unternehmen/team, den die Next.js <Link>-Komponente für das clientseitige Routing nutzen kann.
Dein nächster Schritt: Die gnadenlose Optimierung
Die Datenstruktur steht, das Redakteurs-Erlebnis ist optimiert und die Inhalte fließen vom Backend ins Frontend. Jetzt geht es ans Eingemachte. Wir verlassen die strukturelle Ebene und betreten die Arena der Hochleistungs-Websites.
Im kommenden Cluster Phase 4: Performance, SEO & Tools widmen wir uns der Königsdisziplin der Frontend-Entwicklung. Wir messen und optimieren die Core Web Vitals. Du lernst, wie du die Time to First Byte (TTFB) und den Largest Contentful Paint (LCP) in Next.js auf absolute Spitzenwerte treibst. Wir integrieren ein rechtssicheres, aber performantes Cookie Consent Tool ohne Layout Shifts (CLS) und härten unser CSS durch rigoroses Tailwind-Purging.
Jetzt starten: Teil 14 – Core Web Vitals, PageSpeed & Cookie Consent

Dietrich Bojko
Senior Webentwickler
Webinteger arbeitet seit vielen Jahren produktiv mit
Linux-basierten Entwicklungsumgebungen unter Windows.
Der Fokus liegt auf
performanten Setups mit WSL 2, Docker, PHP, Node.js und modernen
Build-Tools in realen Projekten –
nicht auf theoretischen Beispielkonfigurationen.
Die Artikel dieser Serie entstehen direkt aus dem täglichen Einsatz in Kunden- und Eigenprojekten und dokumentieren bewusst auch typische Fehler, Engpässe und bewährte Workarounds.


