
Das perfekte Laravel Next.js Setup: Projektstruktur für dein Headless CMS

Das nackte Fundament: Ein schlankes Laravel-Backend aufsetzen
Wie oft hast du schon wertvolle Lebenszeit damit verschwendet, eine zerschossene lokale PHP-Umgebung zu reparieren? Früher saß ich regelmäßig bis tief in die Nacht vor flackernden Terminal-Fehlermeldungen. MAMP, XAMPP oder wilde Homebrew-Setups blockierten sich gegenseitig. Ein unsichtbarer Tippfehler in der php.ini – und das gesamte architektonische Kartenhaus stürzte ein. Es war frustrierend und raubte jede Motivation.
Warum machen wir Entwickler uns das Leben an diesem Punkt oft so schwer? Meistens, weil wir an alten Gewohnheiten festhalten.
Damit machen wir jetzt Schluss. Für unser Enterprise-CMS brauchen wir ein Fundament, das absolut stabil ist und uns nicht im Weg steht. Ein sauberes Laravel Next.js Setup ist der einzige Schlüssel, um Backend und Frontend professionell zu entkoppeln. Wir trennen die Welten strikt: Ein dediziertes Laravel 12 Backend als reine Datenkrake und ein Next.js Frontend für die blitzschnelle Darstellung.
Lass uns die Kommandozentrale für dein neues Laravel Next.js Setup hochziehen.
Schritt 1: Das Backend aufsetzen (Laravel Herd)
Um Abhängigkeits-Konflikte für immer aus unserem Leben zu verbannen, nutzen wir Laravel Herd. Es ist blitzschnell, kommt ohne Docker-Overhead (wie bei Laravel Sail) aus und installiert PHP, Composer und Node direkt als isolierte Pakete auf deinem Mac oder Windows-Rechner. Lade dir Herd einfach von der offiziellen Website herunter und starte es.
Sobald das grüne "H" in deiner Menüleiste leuchtet, öffne dein Terminal. Wir navigieren in unseren bevorzugten Entwicklungsordner und initialisieren das Projekt. Da wir ein Headless CMS bauen, benötigen wir keine klassischen Blade-Views. Laravel bietet dafür einen genialen Schalter.
Tippe folgenden Befehl ein:
laravel new cms-backend --apiWas passiert hier genau? Das Flag --api ist unser magischer Schlüssel. Laravel versteht dadurch sofort unsere Absicht und verzichtet darauf, überflüssigen Frontend-Ballast zu installieren. Stattdessen konfiguriert es das Framework als reine, schlanke REST-Schnittstelle. Es bereitet sofort die API-Routen vor und entfernt alles, was mit sitzungsbasiertem Web-Routing (wie klassischen Cookies für Blade) zu tun hat – wir werden die Authentifizierung später ohnehin strikt über Tokens oder Sanctum SPA-Auth lösen.
Sobald der Installer durchgelaufen ist, navigieren wir in das frische Verzeichnis:
cd cms-backendUm zu prüfen, ob unsere lokale Maschine richtig schnurrt, starten wir den integrierten Server (falls du die Herd-Pfade nicht ohnehin schon im Browser über cms-backend.test aufrufst):
php artisan serveDein Backend läuft. Es atmet. Ein nacktes, unbeschriebenes Blatt Papier, das nur darauf wartet, mit komplexer Geschäftslogik gefüllt zu werden.

Der Datentresor: Die MySQL-Datenbank sicher anbinden
Unser Backend steht, aber ein CMS ohne Gedächtnis ist nutzlos. Wir brauchen einen Ort, an dem unsere Artikel, Benutzer und Einstellungen absolut sicher liegen.
Standardmäßig liefert Laravel 12 eine schlanke SQLite-Datenbank aus. Das ist großartig für schnelle Prototypen, aber wir bauen hier eine Enterprise-Architektur. Sobald dein CMS später skaliert und du vielleicht mehrere Serverinstanzen oder komplexe Suchabfragen hast, stößt SQLite an seine architektonischen Grenzen. Wir setzen daher von Beginn an auf MySQL (oder wahlweise PostgreSQL).
Wenn du Laravel Herd nutzt, empfehle ich dir das kleine, aber extrem feine Tool DBngin (oder Herd Pro, falls du das hast). Es startet dir einen lokalen MySQL-Server in Sekundenschnelle, ganz ohne den schweren Ballast von Docker-Containern für die Datenbank.
Schritt 2: Die Datenbank-Verbindung konfigurieren
Hast du deinen MySQL-Server lokal gestartet? Perfekt. Lege dort über dein bevorzugtes Datenbank-Tool (wie TablePlus oder Sequel Ace) eine neue, leere Datenbank an. Nennen wir sie headless_cms.
Jetzt müssen wir unserem Laravel-Backend den Weg zu dieser neuen Datenbank zeigen. Öffne dein Projekt in deinem Code-Editor (zum Beispiel VS Code oder PhpStorm) und suche die .env Datei im Hauptverzeichnis. Diese Datei ist der Tresor für alle umgebungsspezifischen Geheimnisse deines Projekts.
Suche den Block mit den Datenbank-Verbindungen und passe ihn exakt so an:
1DB_CONNECTION=mysql
2DB_HOST=127.0.0.1
3DB_PORT=3306
4DB_DATABASE=headless_cms
5DB_USERNAME=root
6DB_PASSWORD=(Hinweis: Bei DBngin ist der Standard-Benutzer oft root ohne Passwort. Passe das an deine lokalen Gegebenheiten an, falls du ein Passwort vergeben hast.)
Warum machen wir das direkt am Anfang? Weil fast alle weiteren Schritte, besonders die Authentifizierung und das Rechtesystem, sofort eine funktionierende Datenbank verlangen.
Lass uns prüfen, ob Laravel mit der Datenbank sprechen kann. Laravel liefert von Haus aus bereits einige Basis-Migrationen mit (für Benutzer, Passwort-Resets und fehlerhafte Jobs). Wir feuern diese nun in unsere leere Datenbank.
Öffne dein Terminal im Projektordner (cms-backend) und tippe:
php artisan migrateWenn deine .env korrekt konfiguriert ist, belohnt dich das Terminal jetzt mit einer befriedigenden, grünen Ausgabe. Es rattert durch die Tabellenerstellung:
1INFO Preparing database.
2
3 Creating migration table ........................ 12ms DONE
4
5 INFO Running migrations.
6
7 0001_01_01_000000_create_users_table ............ 24ms DONE
8 0001_01_01_000001_create_cache_table ............ 11ms DONE
9 0001_01_01_000002_create_jobs_table ............. 18ms DONESiehst du das? Dein Backend ist nun offiziell mit der Datenbank verheiratet. Die Tabellen users, sessions und cache stehen bereit. Das Fundament ist gegossen.
Als Nächstes müssen wir uns um die Struktur des Frontends kümmern, bevor wir beide Welten miteinander kommunizieren lassen.

Die Karosserie: Das Next.js Frontend initialisieren
Der Motor unseres Sportwagens ist erfolgreich im Chassis montiert und schnurrt leise. Laravel wartet in der Dunkelheit der Datenbank auf seine ersten Befehle. Doch ein Motor allein gewinnt keine Rennen; wir brauchen eine aerodynamische Karosserie. Wir brauchen Next.js.
Wechseln wir die Seiten. Raus aus der PHP-Welt, rein in das moderne JavaScript-Ökosystem.
Erinnerst du dich an die Zeiten, als wir React-Anwendungen mühsam mit Webpack und Babel von Hand zusammenkleben mussten? Ein einziges Konfigurations-Chaos, das oft Tage dauerte. Heute haben wir Werkzeuge, die uns diese Last komplett abnehmen. Für unsere Headless-Architektur nutzen wir den Next.js App Router. Letztes Jahr ignorierte ich bei einem großen E-Commerce-Kunden den App Router zugunsten des alten Pages-Routers, weil ich mich in meiner Komfortzone befand. Die spätere Migration auf React Server Components hat mein Team Wochen an Entwicklungszeit gekostet. Mach diesen Fehler nicht. Wir bauen direkt auf dem modernsten Fundament.
Öffne ein zweites Terminal-Fenster. Achte darauf, dass du dich auf der gleichen Verzeichnisebene befindest wie dein cms-backend Ordner. Wir wollen beide Systeme sauber nebeneinanderliegen haben.
Feuere diesen Befehl ab, um die Magie zu starten:
npx create-next-app@latest cms-frontendSofort wird dich der Installer mit einer Reihe von Architektur-Fragen bombardieren. Antworte exakt wie folgt, um unser Setup auf Enterprise-Niveau zu hieven:
1What is your project named? cms-frontend
2Would you like to use TypeScript? Yes
3Would you like to use ESLint? Yes
4Would you like to use Tailwind CSS? Yes
5Would you like to use `src/` directory? Yes
6Would you like to use App Router? (recommended) Yes
7Would you like to customize the default import alias (@/*)? NoWarum zwinge ich dich hier zu TypeScript? Weil versteckte Typenfehler, die erst im Browser des Nutzers explodieren, der absolute Albtraum sind. TypeScript ist unsere Lebensversicherung. Und der src/-Ordner? Er hält das Hauptverzeichnis frei von Komponenten-Salat und trennt Konfigurationsdateien sauber vom eigentlichen Code.
Sobald der Installationsprozess durchgelaufen ist und alle Node-Pakete geladen wurden, navigieren wir in unser neues Reich:
cd cms-frontendDamit dieses Frontend später weiß, mit wem es sprechen muss, etablieren wir jetzt die Kommunikationswege. Wir benötigen Umgebungsvariablen. Next.js nutzt dafür eine .env.local Datei. Erstelle diese Datei direkt im Hauptverzeichnis deines cms-frontend Ordners.
Füge diese beiden Zeilen ein:
# Die URL für Server-Side Requests (RSC, Route Handlers)
BACKEND_URL=http://cms-backend.test
# Die URL für Client-Side Requests (falls wir direkt aus dem Browser fetchen)
NEXT_PUBLIC_BACKEND_URL=http://cms-backend.test(Falls du Laravel nicht über Herd mit der .test Domain betreibst, trage hier http://localhost:8000 oder deine entsprechende Backend-URL ein).
Wir trennen hier bewusst zwischen einer privaten und einer öffentlichen Variable (gekennzeichnet durch NEXT_PUBLIC_). Das schützt unsere internen Server-Routen und erlaubt uns gleichzeitig, flexible API-Aufrufe aus dem Client heraus zu tätigen.
Lass uns prüfen, ob das neue Frontend atmet. Starte den Entwicklungsserver:
npm run devÖffne deinen Browser und steuere http://localhost:3000 an. Wenn dich das dunkle, stylische Next.js-Logo begrüßt, hast du den Frontend-Grundstein erfolgreich gelegt.
Die Symbiose rückt näher. Beide Systeme existieren nun, sind aber noch völlig blind füreinander.

Die sichere Brücke: CORS konfigurieren und den ersten API-Test feuern
Wir haben jetzt zwei hochgezüchtete Systeme, die isoliert voneinander existieren. Wenn unser Next.js-Frontend jetzt versuchen würde, Daten aus dem Laravel-Backend anzufordern, würde der Browser sofort eine grellrote Fehlermeldung in die Konsole spucken.
CORS.
Cross-Origin Resource Sharing ist der gnadenlose Türsteher des Internets. Da unser Frontend auf http://localhost:3000 läuft und das Backend unter http://cms-backend.test (oder Port 8000), geht der Browser von einem feindlichen Übergriff aus und blockiert die Antwort. Wir müssen dem Türsteher explizit sagen, wer auf die VIP-Lounge zugreifen darf.
Schritt 3: Den Türsteher instruieren (CORS konfigurieren)
Wechsle zurück in deinen Code-Editor, in dem dein Laravel-Projekt geöffnet ist. Seit den neuesten Laravel-Versionen sind einige Konfigurationsdateien standardmäßig versteckt, um das Projekt schlank zu halten. Wir holen uns die CORS-Konfiguration ans Tageslicht.
Führe in deinem Laravel-Terminal folgenden Befehl aus:
php artisan config:publish corsJetzt findest du im Ordner config eine neue Datei namens cors.php. Öffne sie. Wir müssen hier zwei chirurgische Eingriffe vornehmen. Zum einen müssen wir die genaue URL unseres Frontends freigeben, zum anderen müssen wir zwingend den Austausch von sensiblen Daten (wie Cookies) erlauben. Letzteres vergessen viele Entwickler und verbringen später Stunden mit der Fehlersuche bei der Authentifizierung.
Passe das Array in der config/cors.php exakt so an:
1return [
2 'paths' => ['api/*', 'sanctum/csrf-cookie'],
3
4 'allowed_methods' => ['*'],
5
6 'allowed_origins' => ['http://localhost:3000'], // Hier geben wir Next.js die Erlaubnis
7
8 'allowed_origins_patterns' => [],
9
10 'allowed_headers' => ['*'],
11
12 'exposed_headers' => [],
13
14 'max_age' => 0,
15
16 'supports_credentials' => true, // EXTREM WICHTIG für spätere Cookie-Authentifizierung!
17];Die Brücke steht. Der Türsteher ist informiert und unser Laravel Next.js Setup kann nun sicher über verschiedene Ports kommunizieren.
Schritt 4: Der erste Herzschlag (API-Test-Route)
Damit wir beweisen können, dass die Kommunikation reibungslos funktioniert, bauen wir einen winzigen Echolot-Test. Wir lassen das Backend auf einen Ping reagieren.
Öffne in Laravel die Datei routes/api.php. (Dank des --api Flags bei der Installation existiert diese bereits und ist startklar). Lösche den eventuell vorhandenen Boilerplate-Code und setze unseren Gesundheits-Check ein:
1<?php
2
3use Illuminate\Support\Facades\Route;
4
5Route::get('/health', function () {
6 return response()->json([
7 'status' => 'success',
8 'message' => 'Das Laravel-Backend atmet und sendet Grüße!',
9 'timestamp' => now()->toDateTimeString()
10 ]);
11});Schritt 5: Die Daten im Frontend empfangen
Jetzt springen wir rüber in die Next.js Welt. Wir nutzen direkt die rohe Kraft der React Server Components (RSC). Das bedeutet, Next.js ruft die Daten bereits auf dem Node-Server ab, bevor auch nur ein einziges Pixel an den Browser des Nutzers gesendet wird. Das ist unfassbar schnell und perfekt für SEO.
Öffne im cms-frontend Projekt die Datei src/app/page.tsx. Wir radieren den gesamten vorgefertigten Startseiten-Code von Vercel aus und schreiben unsere eigene Logik.
Ersetze den Inhalt komplett durch diesen Code:
1// Da wir hier kein 'use client' am Anfang stehen haben,
2// ist dies automatisch eine React Server Component.
3
4export default async function Home() {
5 // Wir nutzen unsere Umgebungsvariable aus der .env.local
6 const backendUrl = process.env.BACKEND_URL;
7 let serverMessage = 'Backend nicht erreichbar.';
8
9 try {
10 // Der Fetch-Call direkt zum Laravel Backend
11 const response = await fetch(`${backendUrl}/api/health`, {
12 cache: 'no-store', // Für diesen Test wollen wir immer frische Daten
13 });
14
15 if (response.ok) {
16 const data = await response.json();
17 serverMessage = data.message;
18 }
19 } catch (error) {
20 console.error('Verbindungsfehler:', error);
21 }
22
23 return (
24 <main className="flex min-h-screen flex-col items-center justify-center bg-zinc-950 text-white p-24">
25 <div className="z-10 max-w-5xl w-full items-center justify-center font-mono text-sm flex flex-col gap-6">
26 <h1 className="text-4xl font-bold tracking-tight text-zinc-100">
27 Headless CMS Architektur
28 </h1>
29
30 <div className="bg-zinc-900 border border-zinc-800 p-8 rounded-xl shadow-2xl">
31 <p className="text-zinc-400 mb-2 uppercase text-xs font-semibold tracking-wider">
32 Live-Status aus Laravel:
33 </p>
34 <p className="text-emerald-400 font-medium text-lg">
35 {serverMessage}
36 </p>
37 </div>
38 </div>
39 </main>
40 );
41}Speichere die Datei. Wenn dein Next.js Entwicklungsserver (npm run dev) noch läuft, wirf jetzt einen Blick auf deinen Browser unter http://localhost:3000.
Dort sollte dich ein tiefschwarzer Bildschirm begrüßen, auf dem in leuchtendem Grün steht: "Das Laravel-Backend atmet und sendet Grüße!"
Spürst du das? Das ist der Moment, in dem die Magie passiert. Zwei völlig getrennte Technologien reichen sich die Hände.

Die Blaupause: Das Post-Modell und die Datenbank-Migration erstellen
Der Herzschlag zwischen Frontend und Backend ist etabliert. Aber ein CMS ohne Inhalte ist wie eine leere Bibliothek. Bevor wir uns in den nächsten Teilen der Serie um komplexe Dashboards und Formulare kümmern, müssen wir die rohe Datenstruktur für unsere Artikel festlegen.
Wir nutzen jetzt die unglaubliche Effizienz von Laravel, um unser Post-Modell, die dazugehörige Datenbank-Migration und eine Factory (für spätere Testdaten) in einem einzigen, eleganten Befehl zu generieren.
Schritt 6: Das Post-Modell und die Migration erstellen
Wechsle in dein Terminal, in dem dein Laravel-Projekt (cms-backend) läuft. Stoppe den Server kurz (STRG + C) oder öffne einen neuen Tab im gleichen Verzeichnis.
Führe diesen Befehl aus:
php artisan make:model Post -mfDas Flag -mf ist ein massiver Zeitsparer. Es sagt Laravel: "Erstelle mir das Modell (Post.php), generiere sofort die passende Datenbank-Migration (m) und bereite eine Factory (f) vor, damit ich später Dummy-Daten generieren kann."
Laravel hat nun eine neue Migrations-Datei im Ordner database/migrations angelegt. Sie endet auf _create_posts_table.php. Öffne diese Datei in deinem Code-Editor.
Hier definieren wir das exakte Schema unserer Artikel. Wir brauchen einen Titel, einen SEO-freundlichen Slug, den eigentlichen Textinhalt und einen Status, ob der Artikel veröffentlicht ist. Passe die up() Methode exakt so an:
1public function up(): void
2{
3 Schema::create('posts', function (Blueprint $table) {
4 $table->id();
5 $table->string('title');
6 $table->string('slug')->unique();
7 $table->text('content')->nullable();
8 $table->boolean('is_published')->default(false);
9 $table->timestamps(); // Erstellt automatisch created_at und updated_at
10 });
11}Als Nächstes müssen wir unserem Laravel-Modell erlauben, diese Felder auch über API-Anfragen zu befüllen (Mass Assignment Protection). Öffne die Datei app/Models/Post.php und füge das $fillable Array hinzu:
1<?php
2
3namespace App\Models;
4
5use Illuminate\Database\Eloquent\Factories\HasFactory;
6use Illuminate\Database\Eloquent\Model;
7
8class Post extends Model
9{
10 use HasFactory;
11
12 // Diese Felder dürfen wir später über unsere API speichern
13 protected $fillable = [
14 'title',
15 'slug',
16 'content',
17 'is_published',
18 ];
19}Die Blaupause ist fertig. Jetzt müssen wir diese Struktur in unsere tatsächliche MySQL-Datenbank gießen. Feuere den Migrations-Befehl im Terminal ab:
php artisan migrateDein Terminal bestätigt dir die Erstellung der posts Tabelle. Das Backend ist nun offiziell bereit, Artikel aufzunehmen.
Ein leeres Array im Frontend ist jedoch langweilig zu testen. Sollen wir im nächsten Schritt unsere frisch erstellte Factory nutzen, um mit einem einzigen Befehl 50 realistische Test-Artikel (Dummy-Data) in die Datenbank zu pumpen und diese über unsere API an Next.js auszuliefern?

Massenproduktion: Reale Testdaten über Factories generieren
Wir haben die Datenbankstruktur für unsere Artikel erfolgreich angelegt, aber eine leere Tabelle bringt uns nicht weiter, wenn wir das echte Verhalten unseres Frontends testen wollen. Stell dir vor, du müsstest jetzt 50 Artikel von Hand in die Datenbank tippen, nur um zu sehen, ob das Layout scrollbar ist. Ein Graus.
Genau hier spielt Laravel eine seiner größten Stärken aus: Factories und Seeder. Damit erzeugen wir auf Knopfdruck hunderte realistische Testdatensätze.
Schritt 7: Die Factory mit Leben füllen
Öffne in deinem Laravel-Projekt (cms-backend) die Datei database/factories/PostFactory.php. Laravel hat diese Datei dank unseres -mf Flags vorhin bereits angelegt.
Wir nutzen jetzt das integrierte Faker-Package, um realistische Dummy-Texte zu generieren. Passe die definition() Methode genau so an:
1<?php
2
3namespace Database\Factories;
4
5use Illuminate\Database\Eloquent\Factories\Factory;
6use Illuminate\Support\Str;
7
8class PostFactory extends Factory
9{
10 public function definition(): array
11 {
12 // Wir generieren einen realistisch klingenden Satz als Titel
13 $title = fake()->sentence(6);
14
15 return [
16 'title' => $title,
17 // Der Slug wird automatisch aus dem Titel gebildet (z.B. "mein-erster-post")
18 'slug' => Str::slug($title),
19 // Vier Absätze Text, zusammenhängend als ein String
20 'content' => fake()->paragraphs(4, true),
21 // 80% der generierten Artikel sollen direkt veröffentlicht sein
22 'is_published' => fake()->boolean(80),
23 ];
24 }
25}Jetzt müssen wir Laravel noch sagen, dass es diese Fabrik auch anwerfen soll. Öffne dazu die Datei database/seeders/DatabaseSeeder.php und ändere die run() Methode wie folgt:
1<?php
2
3namespace Database\Seeders;
4
5use App\Models\Post;
6use Illuminate\Database\Seeder;
7
8class DatabaseSeeder extends Seeder
9{
10 public function run(): void
11 {
12 // Wir weisen die Factory an, exakt 50 Artikel zu produzieren
13 Post::factory(50)->create();
14 }
15}Geh in dein Terminal und führe diesen Befehl aus:
php artisan db:seedDein Terminal meldet Vollzug. Wenn du jetzt in dein Datenbank-Tool (wie DBngin/TablePlus) schaust, wirst du sehen, dass deine posts-Tabelle prall gefüllt ist mit 50 individuellen Artikeln.
Schritt 8: Die Daten an Next.js ausliefern
Wir haben die Daten. Jetzt müssen wir sie durch unsere Schnittstelle pressen. Öffne wieder die routes/api.php in Laravel. Wir ersetzen unseren kleinen Health-Check von vorhin durch einen echten Endpunkt, der unsere Artikel ausliefert.
1<?php
2
3use Illuminate\Support\Facades\Route;
4use App\Models\Post;
5
6// Wir holen nur veröffentlichte Artikel, sortieren sie nach dem neuesten Datum und nehmen die ersten 10
7Route::get('/posts', function () {
8 $posts = Post::where('is_published', true)
9 ->latest()
10 ->take(10)
11 ->get();
12
13 return response()->json($posts);
14});Wechseln wir zurück in unser Next.js Frontend. Wir wollen diese frischen Daten jetzt auf unserer Startseite anzeigen.
Öffne die Datei src/app/page.tsx in deinem cms-frontend Projekt. Wir erweitern unsere React Server Component, um die Liste der Artikel abzurufen und schick zu rendern:
1export default async function Home() {
2 const backendUrl = process.env.BACKEND_URL;
3 let posts = [];
4
5 try {
6 // Wir fetchen jetzt unsere neue /api/posts Route
7 const response = await fetch(`${backendUrl}/api/posts`, {
8 cache: 'no-store',
9 });
10
11 if (response.ok) {
12 posts = await response.json();
13 }
14 } catch (error) {
15 console.error('Fehler beim Abrufen der Artikel:', error);
16 }
17
18 return (
19 <main className="flex min-h-screen flex-col items-center bg-zinc-950 text-zinc-300 p-12 md:p-24">
20 <div className="max-w-4xl w-full flex flex-col gap-12">
21
22 <header className="border-b border-zinc-800 pb-8">
23 <h1 className="text-4xl md:text-5xl font-extrabold tracking-tight text-white mb-4">
24 Headless CMS Blog
25 </h1>
26 <p className="text-zinc-400 text-lg">
27 Direkt gerendert aus unserer pfeilschnellen Laravel 12 API.
28 </p>
29 </header>
30
31 <div className="grid grid-cols-1 gap-8">
32 {posts.length > 0 ? (
33 posts.map((post: any) => (
34 <article key={post.id} className="bg-zinc-900 border border-zinc-800 p-8 rounded-2xl hover:border-zinc-700 transition-colors">
35 <h2 className="text-2xl font-bold text-zinc-100 mb-3">
36 {post.title}
37 </h2>
38 <p className="text-zinc-400 line-clamp-3 leading-relaxed">
39 {post.content}
40 </p>
41 <div className="mt-6 flex items-center text-sm text-emerald-500 font-medium">
42 Lesen →
43 </div>
44 </article>
45 ))
46 ) : (
47 <div className="text-center p-12 bg-zinc-900 rounded-2xl border border-zinc-800">
48 <p>Keine Artikel gefunden. Läuft dein Backend?</p>
49 </div>
50 )}
51 </div>
52
53 </div>
54 </main>
55 );
56}Speichere die Datei und wechsle in deinen Browser (http://localhost:3000).
Boom! Da sind sie. Eine wunderschön formatierte Liste deiner Artikel, direkt aus der Laravel-Datenbank gezogen, über die API transportiert und von Next.js auf dem Server blitzschnell in HTML gerendert.
Wir haben nun eine voll funktionsfähige, entkoppelte Basis-Architektur. Sollen wir im nächsten Teil das Ganze strukturieren, indem wir in Laravel einen sauberen Controller anlegen und die Daten durch eine API-Resource schicken, um die JSON-Struktur für die Zukunft professionell zu normieren?

Professionelle Schnittstellen: Daten mit Controllern und API-Resources normieren
Es ist großartig, dass unser Frontend bereits mit der Datenbank kommuniziert. Aber schauen wir uns unseren aktuellen Laravel-Code in der routes/api.php an. Wir haben die gesamte Logik – Datenbankabfrage und Rückgabe – direkt in die Routendefinition geklatscht. Für einen schnellen Test ist das völlig in Ordnung. Für ein Enterprise-System ist es ein Wartungs-Albtraum.
Stell dir vor, du hast später 50 verschiedene API-Endpunkte. Deine Routendatei würde hunderte Zeilen lang und völlig unleserlich werden. Außerdem geben wir aktuell einfach das komplette Datenbankmodell aus. Was, wenn wir später sensible Daten (wie Entwurfs-Notizen oder interne IDs) in der Tabelle haben, die das Frontend niemals sehen darf?
Wir müssen unsere Architektur jetzt professionell aufräumen. Wir führen den Controller als Manager ein und nutzen API Resources als unsere strikte Qualitätskontrolle für die Daten.
Schritt 9: Den Controller und die Resource erstellen
Wechsle in dein Laravel-Terminal (cms-backend) und lass uns die Struktur aufbauen. Wir erstellen einen Controller in einem separaten Api-Ordner, um ihn sauber von potenziellen Web-Controllern zu trennen.
php artisan make:controller Api/PostControllerDirekt danach erschaffen wir unsere API-Resource für das Post-Modell:
php artisan make:resource PostResourceWas genau ist eine API Resource? Betrachte sie als einen Übersetzer. Sie nimmt ein rohes Laravel-Modell und entscheidet exakt, welche Felder in welcher Form in JSON umgewandelt und an das Frontend gesendet werden.
Öffne die neu erstellte Datei app/Http/Resources/PostResource.php. Passe die toArray() Methode wie folgt an:
1<?php
2
3namespace App\Http\Resources;
4
5use Illuminate\Http\Request;
6use Illuminate\Http\Resources\Json\JsonResource;
7
8class PostResource extends JsonResource
9{
10 public function toArray(Request $request): array
11 {
12 // Hier definieren wir den exakten JSON-Vertrag mit unserem Frontend
13 return [
14 'id' => $this->id,
15 'title' => $this->title,
16 'slug' => $this->slug,
17 // Wir formatieren das Datum direkt im Backend für den Nutzer
18 'published_at' => $this->created_at->format('d.m.Y'),
19 // Eine kleine Vorschau des Textes für die Blog-Übersicht
20 'excerpt' => str()->limit($this->content, 120),
21 'content' => $this->content,
22 ];
23 }
24}Siehst du den Vorteil? Unser Frontend bekommt nun einen perfekt formatierten excerpt und ein fertiges published_at Datum geliefert. Das entlastet Next.js und sorgt für eine einheitliche Logik.
Schritt 10: Den Controller programmieren
Jetzt öffnen wir den frisch generierten app/Http/Controllers/Api/PostController.php. Hier verlagern wir unsere Abfragelogik hinein und jagen die Ergebnisse durch unsere neue Resource.
1<?php
2
3namespace App\Http\Controllers\Api;
4
5use App\Http\Controllers\Controller;
6use App\Models\Post;
7use App\Http\Resources\PostResource;
8use Illuminate\Http\Request;
9
10class PostController extends Controller
11{
12 public function index()
13 {
14 // Logik holen
15 $posts = Post::where('is_published', true)
16 ->latest()
17 ->take(10)
18 ->get();
19
20 // Daten durch die Resource jagen und als Kollektion zurückgeben
21 return PostResource::collection($posts);
22 }
23}Schritt 11: Das Routing aufräumen
Unsere routes/api.php können wir nun radikal verschlanken. Öffne die Datei, lösche den alten Code für die /posts-Route und setze den Verweis auf unseren neuen Controller:
1<?php
2
3use Illuminate\Support\Facades\Route;
4use App\Http\Controllers\Api\PostController;
5
6// Sauber, lesbar und erweiterbar
7Route::get('/posts', [PostController::class, 'index']);Schritt 12: Next.js an die neue Datenstruktur anpassen
Wenn du jetzt in deinem Browser die Next.js-Seite (http://localhost:3000) neu lädst, wirst du eine böse Überraschung erleben: Deine Artikel sind verschwunden! Warum?
Wenn Laravel eine Resource::collection zurückgibt, wird das gesamte JSON-Array aus Sicherheits- und Standardisierungsgründen automatisch in ein übergeordnetes data-Objekt gewickelt. Unsere API antwortet jetzt nicht mehr mit [...], sondern mit { "data": [...] }. Das ist der offizielle REST-Standard.
Wir müssen unserem Frontend sagen, dass es in dieses data-Objekt greifen soll.
Öffne im cms-frontend Projekt die Datei src/app/page.tsx und suche die Stelle, an der wir das JSON parsen. Passe sie an:
1// ... vorheriger Fetch-Code ...
2 if (response.ok) {
3 const jsonResponse = await response.json();
4 // Wir greifen explizit auf den 'data' Schlüssel zu
5 posts = jsonResponse.data;
6 }Nutzen wir doch gleich unsere neuen Felder aus der Resource. Tausche den HTML-Teil für die Artikel-Schleife weiter unten in der Datei wie folgt aus:
1posts.map((post: any) => (
2 <article key={post.id} className="bg-zinc-900 border border-zinc-800 p-8 rounded-2xl hover:border-zinc-700 transition-colors">
3 <div className="flex justify-between items-center mb-3">
4 <h2 className="text-2xl font-bold text-zinc-100">
5 {post.title}
6 </h2>
7 <span className="text-sm text-zinc-500 bg-zinc-950 px-3 py-1 rounded-full">
8 {post.published_at}
9 </span>
10 </div>
11 <p className="text-zinc-400 leading-relaxed">
12 {post.excerpt}
13 </p>
14 <div className="mt-6 flex items-center text-sm text-emerald-500 font-medium">
15 Weiterlesen →
16 </div>
17 </article>
18 ))Speichere alles ab. Schau in deinen Browser.
Deine Artikel sind zurück. Sie haben jetzt ein schickes, formatiertes Datum oben rechts und der Text wird sauber nach 120 Zeichen abgeschnitten, exakt so, wie wir es in der Laravel-Resource definiert haben.
Wir haben das Fundament nun perfekt ausbalanciert. Das Backend ist strikt und strukturiert, das Frontend schnell und responsiv.

Paginierung einbauen: Mühelos durch hunderte Artikel blättern
Wir haben 50 großartige Artikel in unserer Datenbank, aber unser Frontend zeigt stur nur die ersten 10 an. Wenn wir diese Begrenzung einfach aufheben und hunderte Artikel auf einmal laden, zwingen wir den Browser unserer Nutzer in die Knie und ruinieren unsere Ladezeiten.
Die Lösung ist ein absoluter Standard: Paginierung (Seitenblättern). In einem klassischen Setup ohne Frameworks kann die Berechnung von Offsets, Limits und der Gesamtseitenanzahl echt nervtötend sein. Mit unserer Laravel-Next.js-Kombination ist es fast schon unverschämt einfach.
Schritt 13: Die Paginierung im Laravel-Backend aktivieren
Erinnerst du dich an unseren PostController? Öffne die Datei app/Http/Controllers/Api/PostController.php.
Wir tauschen die harte Begrenzung (take(10)->get()) gegen die integrierte Paginierungs-Engine von Laravel aus. Ändere die index-Methode genau so ab:
1<?php
2
3namespace App\Http\Controllers\Api;
4
5use App\Http\Controllers\Controller;
6use App\Models\Post;
7use App\Http\Resources\PostResource;
8
9class PostController extends Controller
10{
11 public function index()
12 {
13 // Statt get() nutzen wir paginate(10)
14 $posts = Post::where('is_published', true)
15 ->latest()
16 ->paginate(10);
17
18 return PostResource::collection($posts);
19 }
20}Das war’s im Backend. Ernsthaft.
Laravel erkennt automatisch, dass du eine Collection durch eine API Resource jagst, die aus einem Paginator stammt. Wenn du jetzt /api/posts im Browser aufrufst, siehst du, dass Laravel die JSON-Struktur massiv erweitert hat. Neben unserem data-Array gibt es jetzt ein meta-Objekt (mit Infos wie current_page, last_page, total) und ein links-Objekt. Der perfekte Datensatz für unser Frontend.
Schritt 14: Das Next.js Frontend anpassen
Wir müssen unserem Next.js Frontend jetzt beibringen, auf welchen Parameter es hören soll (z.B. ?page=2) und entsprechende Buttons zum Blättern rendern. Da wir den Next.js App Router nutzen, können wir die URL-Parameter direkt in unserer Server Component abgreifen.
Öffne die src/app/page.tsx in deinem cms-frontend Projekt. Wir erweitern die Komponente, um searchParams zu empfangen und die Pagination-Buttons unter die Artikel zu setzen.
Ersetze den gesamten Code durch diese finale Version für Teil 1:
1import Link from 'next/link';
2
3// Next.js übergibt searchParams automatisch an Page-Komponenten
4export default async function Home({
5 searchParams,
6}: {
7 searchParams: Promise<{ [key: string]: string | string[] | undefined }>
8}) {
9 const backendUrl = process.env.BACKEND_URL;
10 let posts = [];
11 let meta = null;
12
13 // Wir extrahieren die aktuelle Seite aus der URL, Standard ist 1
14 const params = await searchParams;
15 const page = params?.page || 1;
16
17 try {
18 // Wir hängen den page-Parameter an unsere API-Anfrage an
19 const response = await fetch(`${backendUrl}/api/posts?page=${page}`, {
20 cache: 'no-store',
21 });
22
23 if (response.ok) {
24 const jsonResponse = await response.json();
25 posts = jsonResponse.data;
26 // Wir speichern die Meta-Daten für unsere Paginierungs-Buttons
27 meta = jsonResponse.meta;
28 }
29 } catch (error) {
30 console.error('Fehler beim Abrufen der Artikel:', error);
31 }
32
33 return (
34 <main className="flex min-h-screen flex-col items-center bg-zinc-950 text-zinc-300 p-12 md:p-24">
35 <div className="max-w-4xl w-full flex flex-col gap-12">
36
37 <header className="border-b border-zinc-800 pb-8">
38 <h1 className="text-4xl md:text-5xl font-extrabold tracking-tight text-white mb-4">
39 Headless CMS Blog
40 </h1>
41 <p className="text-zinc-400 text-lg">
42 Seite {meta?.current_page || 1} von {meta?.last_page || 1}
43 </p>
44 </header>
45
46 {/* Artikel-Liste */}
47 <div className="grid grid-cols-1 gap-8">
48 {posts.length > 0 ? (
49 posts.map((post: any) => (
50 <article key={post.id} className="bg-zinc-900 border border-zinc-800 p-8 rounded-2xl hover:border-zinc-700 transition-colors">
51 <div className="flex justify-between items-center mb-3">
52 <h2 className="text-2xl font-bold text-zinc-100">
53 {post.title}
54 </h2>
55 <span className="text-sm text-zinc-500 bg-zinc-950 px-3 py-1 rounded-full">
56 {post.published_at}
57 </span>
58 </div>
59 <p className="text-zinc-400 leading-relaxed">
60 {post.excerpt}
61 </p>
62 </article>
63 ))
64 ) : (
65 <div className="text-center p-12 bg-zinc-900 rounded-2xl border border-zinc-800">
66 <p>Keine Artikel gefunden.</p>
67 </div>
68 )}
69 </div>
70
71 {/* Paginierungs-Navigation */}
72 {meta && meta.last_page > 1 && (
73 <div className="flex justify-between items-center pt-8 border-t border-zinc-800 mt-4">
74 {meta.current_page > 1 ? (
75 <Link
76 href={`/?page=${meta.current_page - 1}`}
77 className="px-6 py-3 bg-zinc-900 border border-zinc-700 rounded-lg hover:bg-zinc-800 text-white transition-all"
78 >
79 ← Vorherige Seite
80 </Link>
81 ) : (
82 <div /> // Platzhalter, damit der "Weiter"-Button rechts bleibt
83 )}
84
85 {meta.current_page < meta.last_page && (
86 <Link
87 href={`/?page=${meta.current_page + 1}`}
88 className="px-6 py-3 bg-emerald-600 border border-emerald-500 rounded-lg hover:bg-emerald-500 text-white transition-all"
89 >
90 Nächste Seite →
91 </Link>
92 )}
93 </div>
94 )}
95
96 </div>
97 </main>
98 );
99}Speichere die Datei und lade deinen Browser (http://localhost:3000) neu.
Scrolle ganz nach unten. Dort leuchtet nun ein grüner "Nächste Seite"-Button. Klicke darauf. Die URL ändert sich blitzschnell zu /?page=2, Next.js fordert die neuen Daten vom Server an und rendert sofort die nächsten 10 Artikel. Oben im Header aktualisiert sich die Anzeige dynamisch auf "Seite 2 von 5".

Zusammenfassung
Wir sind nicht mit theoretischen Konzepten auf der Stelle getreten, sondern haben direkt architektonische Fakten geschaffen. Deine lokale Entwicklungsumgebung läuft isoliert und rasend schnell. Das Backend (Laravel 12) und das Frontend (Next.js 19) existieren nicht nur, sie kommunizieren bereits sicher über die CORS-Brücke miteinander.
Wir haben die erste Datenbankstruktur für unsere Artikel gegossen, sie über Factories mit massenhaft realistischen Testdaten gefüllt und gelernt, wie wir diese rohen Daten durch API-Resources in ein perfektes, vorhersehbares JSON-Format zwingen. Zum krönenden Abschluss haben wir die serverseitige Paginierung implementiert, sodass Next.js hunderte Datensätze elegant und ressourcenschonend verarbeitet.
Das war der Rohbau. Jetzt ziehen wir die Wände hoch.
Teil der Serie
Headless CMS mit Laravel und Next.js
Häufig gestellte Fragen (FAQ)
Ausblick auf Teil 2: Skalierbares Datenbank-Design
Ein CMS besteht selten nur aus einer flachen Liste von Artikeln. Im nächsten Teil unserer Serie widmen wir uns der Königsdisziplin der Backend-Entwicklung: relationalen Datenstrukturen.
Wir werden unser isoliertes Post-Modell in ein intelligentes Netzwerk integrieren. Wie verknüpfen wir Artikel sauber mit Kategorien (One-to-Many oder Many-to-Many)? Wie ordnen wir Autoren zu? Wir modellieren Fremdschlüssel (Foreign Keys), schreiben komplexe Migrationen und konfigurieren die Eloquent-Beziehungen so, dass unsere API später selbst tief verschachtelte Daten (z. B. "Gib mir alle Artikel aus der Kategorie 'Tech' inklusive der Autoren-Namen") mit minimalen Datenbankabfragen ausliefern kann. Das wird dein Verständnis für Datenmodellierung auf das nächste Level heben.
Hier geht es zu Teil 2: Datenbank-Design für ein skalierbares Headless CMS

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.


