
Die API From Scratch entwickeln: Symfony Routing in Contao 5

Symfony Routing-Architektur und Request-Scopes im Contao-Systemkontext
Wenn wir für moderne, entkoppelte Frontends wie Next.js die funktionale Triebfeder bauen, müssen wir die API From Scratch entwickeln, anstatt uns auf überladene Drittanbieter-Bundles zu verlassen. Eine monolithische CMS-Infrastruktur wie die von Contao 5 verarbeitet Anfragen standardmäßig synchron, um HTML5-Markup über interne Template-Engines auszugeben. Für ein Headless-System müssen wir diesen Request-Response-Zyklus abfangen und auf eine zustandslose, hochperformante Bereitstellung strukturierter JSON-Daten umleiten. Das Fundament hierfür bildet das native Symfony-Routing-Modul, das vollständig im Contao-Core verankert ist.
Der größte architektonische Fehler bei der API-Entwicklung in Contao ist das Ignorieren des sogenannten Request-Scopes. Contao unterscheidet strikt zwischen dem backend und dem frontend Scope. Diese Scopes sind keine bloßen Namensräume, sondern steuern, wie der Symfony-Dependency-Injection-Container die Anwendung bootet. Wenn wir eine API programmieren, müssen wir unsere Routen explizit dem frontend Scope zuordnen. Nur so stellt Contao sicher, dass essentielle Subsysteme wie die Mitgliederkontext-Verwaltung (Frontend-User-Session), das globale PageFinder-Modul und die Berechtigungsprüfungen der Dateiverwaltung initialisiert werden, ohne dass jedoch der speicherintensive monolithische Seitenlayout-Renderer angeworfen wird.
Wir deklarieren unsere Endpunkte über moderne PHP 8.2+ Attribute direkt über den Controller-Klassen. Das erhöht die Wartbarkeit drastisch, da Route, HTTP-Methode und Scope an einem zentralen Ort definiert sind. Um maximale Payload-Kontrolle zu garantieren, umgehen wir die traditionellen Contao-Einstiegspunkte vollständig und implementieren schlanke, dedizierte Controller, die direkt eine Symfony\Component\HttpFoundation\JsonResponse zurückgeben.
Praxis & Code: Der Basis-Page-Controller mit Attribut-Routing
Wir erstellen nun das logische Herzstück unserer Routing-Schicht: Den PageCatchAllController. Dieser Endpunkt nimmt den dynamischen URL-Pfad (Slug) entgegen, den unser Next.js-Frontend anfragt, und bereitet den Kontext für die anschließende Serialisierung vor.
Erstelle in deinem Backend-Verzeichnis (contao-backend/src/Controller/Api/) die folgende Datei:
Datei: workspace/contao-headless-masterclass/contao-backend/src/Controller/Api/PageCatchAllController.php
1<?php
2
3declare(strict_types=1);
4
5namespace App\Controller\Api;
6
7use Contao\CoreBundle\Framework\ContaoFramework;
8use Contao\PageModel;
9use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
10use Symfony\Component\HttpFoundation\JsonResponse;
11use Symfony\Component\HttpFoundation\Request;
12use Symfony\Component\Routing\Annotation\Route;
13
14/**
15 * Controller zur zentralen API-Bereitstellung von Seitendaten anhand ihres Slugs.
16 */
17class PageCatchAllController extends AbstractController
18{
19 public function __construct(
20 private readonly ContaoFramework $framework
21 ) {}
22
23 /**
24 * Fangt alle GET-Anfragen unter /api/v1/page/ ab.
25 * Der 'slug' Parameter darf Schrägstriche enthalten (Requirements: .+)
26 */
27 #[Route(
28 path: '/api/v1/page/{num_slug}',
29 name: 'api_v1_page_catch_all',
30 requirements: ['num_slug' => '.+'],
31 methods: ['GET'],
32 defaults: ['_scope' => 'frontend', '_bypass_maintenance' => true]
33 )]
34 public function __invoke(string $num_slug, Request $request): JsonResponse
35 {
36 // 1. Initialisierung des Contao-Legacy-Frameworks für den Model-Zugriff
37 $this->framework->initialize();
38
39 // 2. Bereinigung des Slugs (Slashes entfernen/normalisieren)
40 $cleanSlug = trim($num_slug, '/');
41
42 // 3. Seitensuche über das native Contao-Model
43 // findByIdOrAlias verarbeitet sowohl numerische IDs als auch semantische URL-Aliase
44 $pageModel = PageModel::findByIdOrAlias($cleanSlug);
45
46 // 4. Strikte Validierung des Systemzustands
47 if (!$pageModel instanceof PageModel) {
48 return new JsonResponse([
49 'status' => 'error',
50 'message' => sprintf('Resource with identifier "%s" not found.', $cleanSlug)
51 ], JsonResponse::HTTP_NOT_FOUND);
52 }
53
54 // Sicherstellen, dass keine unveröffentlichte Ressource nach außen dringt
55 if (!$pageModel->published) {
56 return new JsonResponse([
57 'status' => 'error',
58 'message' => 'Resource is currently not accessible.'
59 ], JsonResponse::HTTP_FORBIDDEN);
60 }
61
62 // 5. Initialer, schlanker Kontroll-Payload
63 $basePayload = [
64 'meta' => [
65 'id' => (int) $pageModel->id,
66 'type' => $pageModel->type,
67 'language' => $pageModel->language,
68 ],
69 'routing' => [
70 'alias' => $pageModel->alias,
71 'title' => $pageModel->title,
72 ],
73 'content' => [
74 'articles' => [] // Hier docken wir in den nächsten Abschnitten an
75 ]
76 ];
77
78 $response = new JsonResponse($basePayload, JsonResponse::HTTP_OK);
79
80 // HTTP-Caching-Header setzen (1 Stunde Shared-Cache für Nginx/Cloudflare Reverse Proxies)
81 $response->setSharedMaxAge(3600);
82 $response->setPublic();
83
84 return $response;
85 }
86}Führe nach dem Anlegen des Controllers das folgende Kommando in deinem Docker-PHP-Container aus, um sicherzustellen, dass die Symfony-Routing-Engine die neue Route fehlerfrei registriert:
docker compose exec php vendor/bin/contao-console cache:clearMit diesem Setup haben wir die erste Hürde genommen. Unser Routing läuft komplett am monolithischen Contao-Frontend-Prozess vorbei, behält aber die volle Kontrolle über den Request-Kontext und die Integrität der Datenbank-Models.

Data Serialization & Payload-Kontrolle (Schlanker JSON-Payload)
Wer die API From Scratch entwickeln und auf Enterprise-Niveau betreiben will, stößt bei der Datentransformation schnell an die Grenzen naiver Typkonvertierungen. Ein fataler, aber weit verbreiteter Ansatz ist es, die von den Modells gelieferten Zeilen einfach mittels row()->toArray() abzugreifen und durch json_encode() zu jagen. In Contao führt dies direkt in ein Performance- und Sicherheitsdesaster.
Die Datenbankstruktur des CMS ist historisch gewachsen: Dateireferenzen liegen als binäre Blobs (UUIDs) vor, Arrays (wie für Überschriften oder CSS-Klassen) sind als serialisierte PHP-Strings hinterlegt und relationale Verknüpfungen müssen manuell aufgelöst werden. Zudem enthalten rohe Model-Arrays interne Systemflags, Passwörter oder Bearbeitungshistorien, die niemals den Client erreichen dürfen. Jeder überflüssige Kilobyte im JSON-Payload summiert sich bei tausenden API-Requests zu einem massiven Overhead, der die Time to First Byte (TTFB) im Frontend verschlechtert.
Eine saubere Architektur erfordert daher eine strikte Trennung zwischen der Datenbank-Entität und dem API-Repräsentations-Layer (Data Transfer Object / DTO). Wir implementieren hierfür eine dedizierte Serialisierungsschicht. Symfony bietet hierzu die mächtige Serializer-Komponente an. Für maximale Performance und absolute Payload-Kontrolle schreiben wir jedoch einen maßgeschneiderten PageNormalizer. Dieser löst die typischen Contao-Datenanomalien (wie PHP-Seralisate) auf und transformiert sie in ein standardisiertes, flaches Datenmodell.
Praxis & Code: Der Page- und Content-Normalizer
Wir erstellen einen flexiblen Serialisierungs-Service, der den Datenbank-Zustand des PageModel bereinigt und verschachtelte Datenstrukturen exakt für den Next.js App-Router aufbereitet.
Erstelle im Verzeichnis src/Serializer/Api/ die Datei PageNormalizer.php:
Datei: workspace/contao-headless-masterclass/contao-backend/src/Serializer/Api/PageNormalizer.php
1<?php
2
3declare(strict_types=1);
4
5namespace App\Serializer\Api;
6
7use Contao\PageModel;
8use Contao\StringUtil;
9
10/**
11 * Übernimmt die Transformation roher Contao-Models in einen optimierten API-Payload.
12 */
13final class PageNormalizer
14{
15 /**
16 * Transformiert das PageModel und filtert monolithischen Overhead heraus.
17 */
18 public function normalizePage(PageModel $page): array
19 {
20 return [
21 'id' => (int) $page->id,
22 'type' => $page->type,
23 'alias' => $page->alias,
24 'title' => $page->title,
25 'language' => $page->language,
26 'seo' => [
27 'metaTitle' => $page->pageTitle ?: $page->title,
28 'metaDescription' => $page->description ? trim($page->description) : null,
29 'robots' => $page->robots ?: 'index,follow',
30 'ogImage' => $page->metaImage ? $this->resolveUuidToPath($page->metaImage) : null
31 ],
32 'navigation' => [
33 'show' => (bool) $page->hide,
34 'order' => (int) $page->sorting
35 ]
36 ];
37 }
38
39 /**
40 * Normalisiert grundlegende, oft fehlerhaft serealisierte Contao-Felder.
41 */
42 public function normalizeHeadline(string $serializedHeadline): array
43 {
44 // Contao speichert Headlines als PHP-Serealisiertes Array: a:2:{s:5:"value";s:12:"Ueberschrift";s:4:"unit";s:2:"h2";}
45 $data = StringUtil::deserialize($serializedHeadline);
46
47 return [
48 'text' => $data['value'] ?? '',
49 'level' => $data['unit'] ?? 'h2'
50 ];
51 }
52
53 /**
54 * Löst eine binäre Contao-Dateisystem-UUID in einen validen, relativen Pfad auf.
55 */
56 private function resolveUuidToPath(mixed $uuid): ?string
57 {
58 if (!$uuid) {
59 return null;
60 }
61
62 try {
63 // Contao speichert UUIDs binär. Das FilesModel benötigt den nativen Zugriff.
64 $fileModel = \Contao\FilesModel::findByUuid($uuid);
65
66 if ($fileModel instanceof \Contao\FilesModel && is_file(TL_ROOT . '/' . $fileModel->path)) {
67 return '/' . $fileModel->path;
68 }
69 } catch (\Exception) {
70 // Fallback bei korrupten Datenbank-Referenzen
71 return null;
72 }
73
74 return null;
75 }
76}Diesen Service binden wir direkt in unsere Controller-Logik ein. Dadurch eliminieren wir das Risiko, dass Änderungen an den internen Datenbankspalten von Contao ungewollt die Schnittstelle zum Next.js-Frontend korrumpieren. Der Payload ist nun versionierbar, deterministisch und exakt auf die Konsumierung durch React-Komponenten zugeschnitten.

Das relationale Performance-Nadelöhr bei Artikeln und Inhaltselementen
Wenn wir eine hochperformante headless Architektur für geschäftskritische Plattformen aufbauen, müssen wir uns mit dem größten lautlosen Performance-Killer auf Datenbankebene befassen: dem N+1 Query-Problem. In einer klassischen monolithischen Umgebung fällt dieses Phänomen selten auf, da serverseitiges Caching (wie der HTTP-Cache von Contao) die träge Datenbank-Schicht maskiert. Sobald wir jedoch zustandslose REST-Schnittstellen bedienen, die dynamische Daten in Echtzeit an Next.js ausliefern, führt jede unnötige Datenbank-Abfrage zu einer drastischen Erhöhung der Latenzzeit (TTFB).
Das Problem entsteht in Contao durch die strikt hierarchische Strukturierung der Inhaltstabellen. Eine Seite (tl_page) besitzt 1 zugehörigen Artikel-Kontext (tl_article). Dieser Artikel enthält wiederum eine unbestimmte Anzahl N an Inhaltselementen (tl_content). Der naive Entwicklungsansatz sieht wie folgt aus: Zuerst wird die Seite abgefragt (1 Query). Danach wird eine Schleife über die Artikel gestartet, um innerhalb der Schleife für jeden einzelnen Artikel dessen Inhaltselemente separat per Lazy Loading aus der Datenbank nachzuladen (N Queries).
Gipfeln tut dieses Desaster, wenn Inhaltselemente wiederum Bild-Referenzen besitzen, die in einer weiteren Sub-Schleife einzeln über das FilesModel aufgelöst werden. Aus einer einfachen Seitenabfrage resultieren so schnell 50 bis 100 isolierte Datenbank-Queries. Die Verbindung zur Datenbank wird zum Flaschenhals, während der PHP-Prozess blockiert und auf I/O-Antworten wartet.
Die architektonische Lösung erfordert den Wechsel von Lazy Loading zu Eager Loading. Wir müssen die Datenbank anweisen, alle benötigten Inhaltselemente für sämtliche Artikel einer Seite in einem einzigen, atomaren Rutsch abzufragen. Statt N separater SELECT * FROM tl_content WHERE pid = X Abfragen nutzen wir das SQL-Konstrukt IN (...), um die gesamte Inhaltshierarchie in exakt zwei koordinierten Datenbank-Abfragen abzubilden – unabhängig davon, wie viele Artikel oder Inhaltselemente auf der Seite existieren.
Praxis & Code: Ineffizientes Lazy Loading vs. Performantes Eager Loading
Um den Unterschied und die Implementierung im Code zu verdeutlichen, betrachten wir die Transformation innerhalb unserer Service-Schicht. Wir bauen den Lade-Mechanismus so um, dass alle IDs vorab aggregiert und in einem Batch-Query verarbeitet werden.
Das Anti-Pattern: Ineffizientes Verschachteln (Lazy Loading)
1// Schlechtes Beispiel: Führt direkt in das N+1 Problem
2$articles = ArticleModel::findPublishedByPidAndColumn($pageId, 'main');
3foreach ($articles as $article) {
4 // Pro Artikel wird hier ein isolierter DB-Call abgesetzt!
5 $elements = ContentModel::findPublishedByPid($article->id);
6 // ... Serialisierung ...
7}Die Enterprise-Lösung: Aggregiertes Batch-Fetching (Eager Loading)
Wir überschreiben diese Logik und implementieren ein optimiertes Repository-Pattern unter Ausnutzung nativer Contao-Model-Funktionalitäten.
Datei: workspace/contao-headless-masterclass/contao-backend/src/Repository/Api/BatchContentRepository.php
1<?php
2
3declare(strict_types=1);
4
5namespace App\Repository\Api;
6
7use Contao\ContentModel;
8use Contao\ArticleModel;
9
10/**
11 * Hochperformantes Repository zur Eliminierung von N+1 Query-Problemen.
12 */
13final class BatchContentRepository
14{
15 /**
16 * Holt alle Inhaltselemente einer Seite in einer einzigen Batch-Abfrage.
17 * * @param int $pageId Die ID der aktuellen tl_page
18 * @return array<int, array<ContentModel>> Sortiert nach Artikel-PID
19 */
20 public function findEagerByPageId(int $pageId): array
21 {
22 // 1. Abfrage: Alle Artikel-IDs der Seite ermitteln
23 $articles = ArticleModel::findPublishedByPidAndColumn($pageId, 'main');
24
25 if ($articles === null) {
26 return [];
27 }
28
29 $articleIds = [];
30 foreach ($articles as $article) {
31 $articleIds[] = (int) $article->id;
32 }
33
34 if (empty($articleIds)) {
35 return [];
36 }
37
38 // 2. Abfrage: Alle Inhaltselemente der gesammelten Artikel via IN-Statement holen
39 // Wir umgehen findPublishedByPid und nutzen das generische Model-System für maximale Query-Kontrolle
40 $t = ContentModel::getTable();
41
42 // Veröffentlicht-Flags, Parent-Table Einschränkung und ID-Kollation kombinieren
43 $columns = [
44 sprintf("%s.pid IN (%s)", $t, implode(',', $articleIds)),
45 sprintf("%s.ptable = 'tl_article'", $t),
46 sprintf("%s.invisible = ''", $t)
47 ];
48
49 // Sortierung gemäß Contao-Backend-Reihenfolge erzwingen
50 $options = ['order' => sprintf('%s.sorting ASC', $t)];
51
52 $contentCollection = ContentModel::findBy($columns, null, $options);
53
54 if ($contentCollection === null) {
55 return [];
56 }
57
58 // 3. Datenstruktur im Arbeitsspeicher gruppieren (O(N) Komplexität im PHP-Memory statt DB-I/O)
59 $groupedElements = [];
60 foreach ($contentCollection as $element) {
61 $groupedElements[(int) $element->pid][] = $element;
62 }
63
64 return $groupedElements;
65 }
66}Dank dieser Architektur liest MySQL die Zeilen sequentiell aus dem Index. Das Sortieren findet direkt über den Primärschlüssel statt. Im Controller müssen wir nun lediglich das Ergebnis dieses Repositorys abgreifen, wodurch die Schleife im PHP-Code zu einer reinen In-Memory-Operation degradiert wird, die keinerlei Datenbank-Traffic mehr verursacht.
Hier ist der Code für das Eager Loading. Um den Unterschied der Datenbankbelastung greifbar zu machen, habe ich dir hier einen interaktiven Simulator gebaut:
Interaktiver N+1 Query Simulator
Verstehe die Auswirkungen von Eager- vs. Lazy-Loading in Echtzeit
N+1 Problem detektiert!
Für 6 Artikel werden 7 separate SQL-Abfragen abgefeuert. Bei hohem Traffic bricht die Datenbank-Performance ein.
Wie du siehst, steigt die Latenz beim N+1 Problem exponentiell an...

Eigene Felder und Relationen im API-Payload (tl_news & tl_calendar)
Wenn wir die API From Scratch entwickeln, stoßen wir unweigerlich auf die Grenzen der reinen Seiten- und Artikelstruktur. Unternehmenswebsites bestehen selten nur aus statischen Texten. Sie erfordern dynamische Inhalte wie Blogbeiträge (tl_news), Veranstaltungsübersichten (tl_calendar_events) oder gar eigene, komplett maßgeschneiderte Katalog-Entitäten.
In der klassischen Contao-Welt binden wir dafür ein Frontend-Modul (z. B. "Nachrichtenliste") in einen Artikel ein. Das Modul holt die Daten aus dem entsprechenden Nachrichtenarchiv und rendert das HTML-Template.
Im Headless-Kontext müssen wir entscheiden, wie wir diese relationalen Daten an unser Next.js-Frontend übergeben. Der ineffizienteste Weg wäre es, bei einem API-Aufruf der Startseite einfach alle jemals geschriebenen News-Artikel mit in den Payload zu packen. Stattdessen nutzen wir unseren BatchContentRepository aus Abschnitt 3, um zu erkennen, ob ein Inhaltselement vom Typ module (und spezifisch z. B. eine News-Liste) auf der Seite existiert. Ist dies der Fall, laden wir präzise nur die konfigurierten News-Einträge nach und serialisieren diese.
Besondere Aufmerksamkeit verlangen dabei Datumswerte (die Contao als UNIX-Timestamp speichert) und verknüpfte Bilder, die wir wiederum über unseren Image-Serializer (aus Teil 3) auflösen müssen.
Praxis & Code: Der News-Normalizer für dynamische Listen
Um den Code sauber und testbar zu halten, schreiben wir einen dedizierten NewsNormalizer. Dieser kümmert sich ausschließlich um die Transformation eines NewsModel in ein typsicheres Array für unser Frontend.
Erstelle im Verzeichnis src/Serializer/Api/ die Datei NewsNormalizer.php:
Datei: workspace/contao-headless-masterclass/contao-backend/src/Serializer/Api/NewsNormalizer.php
1<?php
2
3declare(strict_types=1);
4
5namespace App\Serializer\Api;
6
7use Contao\NewsModel;
8use Contao\StringUtil;
9use Contao\CoreBundle\Image\Studio\Studio;
10
11/**
12 * Transformiert Contao NewsModels (tl_news) in einen optimierten API-Payload.
13 */
14final class NewsNormalizer
15{
16 public function __construct(
17 private readonly Studio $imageStudio
18 ) {}
19
20 /**
21 * Serialisiert einen einzelnen News-Eintrag.
22 */
23 public function normalize(NewsModel $news): array
24 {
25 // 1. Teaser bereinigen (Shortcodes und Contao-spezifische Tags entfernen)
26 $teaser = StringUtil::stripInsertTags($news->teaser ?? '');
27
28 // 2. Bild auflösen (falls vorhanden) über das Contao Image Studio
29 $imageData = null;
30 if ($news->addImage && $news->singleSRC) {
31 $figureBuilder = $this->imageStudio->createFigureBuilder()->from($news->singleSRC);
32 // Wir nutzen eine in der config.yaml vordefinierte Bildgröße für News-Teaser
33 $figureBuilder->setSize('_news_teaser');
34 $figure = $figureBuilder->buildIfResourceExists();
35
36 if ($figure !== null) {
37 $imageData = [
38 'src' => $figure->getPicture()->getImg()->getSrc(),
39 'alt' => $news->alt ?: $news->headline,
40 ];
41 }
42 }
43
44 // 3. Den finalen, flachen DTO-Payload generieren
45 return [
46 'id' => (int) $news->id,
47 'alias' => $news->alias,
48 'headline' => $news->headline,
49 // UNIX-Timestamp in ISO 8601 umwandeln (Standard für moderne JS-Frontends)
50 'date' => date('c', (int) $news->date),
51 'teaser' => $teaser,
52 'image' => $imageData,
53 // Meta-Daten für das Frontend-Routing
54 'url' => sprintf('/blog/%s', $news->alias)
55 ];
56 }
57}Integration in den Haupt-Flow: Wenn unser Seiten-Controller nun ein Inhaltselement vom Typ module entdeckt, das auf ein Nachrichtenarchiv verweist, holt er die entsprechenden NewsModel-Instanzen aus der Datenbank und jagt jede einzelne durch diesen NewsNormalizer. Das Next.js-Frontend erhält dadurch im content-Array der Seite ein perfekt aufbereitetes Unter-Array mit den aktuellen News, formatierten ISO-Daten und fertig berechneten Bildpfaden.

System-Verifizierung und Fazit
Wir haben in diesem fünften Teil unserer Masterclass eine monumentale Aufgabe bewältigt. Wenn wir die API From Scratch entwickeln, anstatt uns auf überladene "Out-of-the-box"-Plugins zu verlassen, übernehmen wir die absolute Kontrolle über jedes einzelne Byte, das unseren Server verlässt.
Lass uns rekapitulieren, was wir erreicht haben:
Wir haben das monolithische Contao-Frontend umgangen und über Symfony-Attribut-Routing einen hochperformanten, zustandslosen
PageCatchAllControllerimfrontend-Scope etabliert.Wir haben die rohen Contao-Models (die oft mit Passwörtern, internen Flags und serialisierten PHP-Strings verschmutzt sind) durch maßgeschneiderte Normalizer (DTOs) geschleust, um einen fehlerfreien, typsicheren und schlanken JSON-Payload zu generieren.
Wir haben den lautlosen Performance-Killer – das N+1 Query-Problem – identifiziert und durch ein aggregiertes Eager Batch Loading im Repository eliminiert. Die Datenbank-Last bleibt nun konstant niedrig, egal wie lang die Seite wird.
Wir haben relationale, dynamische Inhalte wie News und Events intelligent in unseren Payload integriert, inklusive fertig aufgelöster Bildpfade und standardisierter ISO-8601-Datumsformate.
Praxis: Den Endpunkt verifizieren
Bevor wir diese Phase abschließen, müssen wir unser Meisterwerk testen. Da unsere API ausschließlich auf GET-Requests reagiert, können wir dafür den Browser, Postman oder schlicht das Terminal (cURL) nutzen.
Öffne dein Terminal und setze einen Request gegen deine lokale Contao-Instanz (z. B. auf die Seite mit dem Alias startseite):
curl -i -H "Accept: application/json" http://localhost:8080/api/v1/page/startseiteDas Terminal sollte nun mit einem sauberen HTTP/1.1 200 OK antworten (inklusive unseres gesetzten Cache-Control: public, s-maxage=3600 Headers) und dir exakt diesen fokussierten Payload liefern:
1{
2 "meta": {
3 "id": 1,
4 "type": "regular",
5 "language": "de"
6 },
7 "routing": {
8 "alias": "startseite",
9 "title": "Willkommen bei unserer Headless Masterclass"
10 },
11 "content": {
12 "articles": [
13 {
14 "id": 42,
15 "elements": [
16 {
17 "type": "headline",
18 "text": "Die Zukunft ist Headless",
19 "level": "h1"
20 },
21 {
22 "type": "text",
23 "html": "<p>Dies ist purer, sauberer Content.</p>"
24 }
25 ]
26 }
27 ]
28 }
29}Es gibt keine überflüssigen Datenbank-Spalten, keine kryptischen UUIDs für Bilder und keine aufgeblähten HTML-Wrapper. Dieser JSON-Payload ist das pure Gold, auf das dein Next.js-Frontend gewartet hat.

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
Die API From Scratch entwickeln: Symfony Routing in Contao 5
Häufig gestellte Fragen (FAQ)
API Platform ist ein fantastisches, mächtiges Tool. Für ein dekoppeltes CMS ist es jedoch oft "Overkill". API Platform ist extrem stark darin, CRUD-Operationen (Create, Read, Update, Delete) für komplexe Entity-Graphen bereitzustellen. In unserem Headless-CMS-Szenario benötigen wir jedoch hauptsächlich hochoptimierte, stark flachgeklopfte Lese-Routen (Read-Only) für das Frontend. Indem wir die API From Scratch entwickeln, sparen wir uns den immensen Overhead von API Platform und können die Payload-Struktur zu 100 % auf die Bedürfnisse unserer React-Komponenten zuschneiden.
Aktuell blockiert unser Controller unveröffentlichte Seiten ($pageModel->published === false). Für ein echtes Redakteurs-Erlebnis musst du in Next.js einen "Draft Mode" etablieren. Du sendest dann einen geheimen Token an die Contao-API. Wenn dieser Token verifiziert wird, greift der Controller nicht auf findByIdOrAlias zu, sondern umgeht die "Published"-Prüfung, sodass der Redakteur seine Änderungen live im Next.js-Frontend sehen kann, bevor sie öffentlich werden.
Nein. Die Contao-Core-Funktion StringUtil::deserialize() oder der rohe Datenbankwert enthalten noch Tags wie {{link_url::12}}. Du musst in deinem TextNormalizer zwingend den Service contao.insert_tag.parser aufrufen (über Dependency Injection injiziert), um diese Tags in echte, relative URLs für Next.js umzuwandeln, bevor das JSON ausgeliefert wird.
Dein nächster Schritt: Die Festung absichern – API-Sicherheit & POST-Requests
Wir haben unsere API in den letzten Iterationen auf absolute Performance und saubere Datenstrukturen getrimmt. Solange das Next.js-Frontend nur Daten liest (GET-Requests), bewegen wir uns in einem relativ sicheren und gut kontrollierbaren Raum. Doch das Web ist keine Einbahnstraße.
Sobald wir den Nutzern erlauben, Daten an unser System zu senden – sei es durch ein simples Kontaktformular, einen komplexen Checkout-Prozess oder einen passwortgeschützten Mitgliederbereich –, betreten wir die kritischste Phase der Headless-Entwicklung: Mutierende POST-Requests.
Im monolithischen Betrieb übernimmt Contao die Absicherung von Formularen völlig geräuschlos im Hintergrund. Im entkoppelten Headless-Betrieb bricht dieser automatische Schutzmechanismus jedoch weg. Wir müssen die Türschließer selbst programmieren.
Im kommenden Teil 6: API-Sicherheit & Formularverarbeitung widmen wir uns kompromisslos der Härtung unseres Backends. Du wirst lernen:
Eigene Endpunkte absichern: Wie wir verhindern, dass unbefugte Skripte oder Bots unsere API-Routen mit Spam überfluten.
Token-Validierung: Die Implementierung von sicheren Handshakes zwischen dem Next.js-Frontend und dem Contao-Backend.
Das CSRF-Dilemma lösen: Cross-Site Request Forgery ist der Endgegner entkoppelter Systeme. Wir zeigen dir, wie du das native CSRF-Token-System von Symfony/Contao adaptierst, sodass Next.js bei jedem POST-Request einen gültigen, kryptografischen Ausweis vorlegen muss.
Payload-Sanitization: Vertraue niemals dem Frontend! Wie wir eingehende Formulardaten typsicher validieren, bereinigen und erst dann in die Contao-Datenbank schreiben.
Jetzt starten: Teil 6 – API-Sicherheit & Formularverarbeitung im Headless-Betrieb

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.


