Data Fetching in Next.js
In diesem Modul lernst du, wie Next.js Datenbankzugriffe und HTTP-Requests grundlegend anders organisiert als Rails — und warum das für viele Szenarien ein echter Gewinn ist.
Data Fetching — kein useEffect mehr!
Wer von React-Anwendungen kommt, kennt das Muster im Schlaf: Komponente rendert, useEffect feuert, ein fetch()-Aufruf läuft los, ein Ladespinner dreht sich, und irgendwann kommen Daten an. Drei Renders, ein Wasserfall, und jede Menge Boilerplate für etwas so Simples wie "zeig mir eine Liste von Notizen".
Next.js App Router bricht dieses Muster auf. Server Components können direkt async/await sein — das heißt, du rufst Prisma, eine externe API oder deine Datenbank genau dort auf, wo du das Ergebnis brauchst: in der Komponente selbst.
In Rails gibt es dafür den Controller. Der NotesController#index-Action holt Daten aus der Datenbank und macht sie dem Template über Instanzvariablen zugänglich. In Next.js ist diese Grenze aufgelöst: Die Serverkomponente ist gleichzeitig Controller und View — sie lebt vollständig auf dem Server, wird nie an den Browser geliefert, und kann deshalb direkt mit dem Datenbankzugriff beginnen.
# app/controllers/notes_controller.rb
class NotesController < ApplicationController
def index
@notes = Note.order(created_at: :desc)
end
end
# app/views/notes/index.html.erb
<% @notes.each do |note| %>
<h2><%= note.title %></h2>
<% end %>// app/notes/page.tsx — Server Component
import { prisma } from "@/lib/prisma";
export default async function NotesPage() {
const notes = await prisma.note.findMany({
orderBy: { createdAt: "desc" },
});
return (
<ul>
{notes.map((note) => (
<li key={note.id}>{note.title}</li>
))}
</ul>
);
}Beachte das async vor function — das ist der Schlüssel. Next.js erwartet, dass Server Components ein Promise zurückgeben dürfen. React wartet dann auf die Auflösung, bevor es den fertigen HTML-Baum an den Client sendet. Der Nutzer sieht niemals einen Ladezustand, weil der HTML bereits vollständig ist, wenn er beim Browser ankommt.
Direkter Datenbankzugriff — keine API nötig
Eines der größten DX-Highlights von Next.js mit dem App Router ist die Möglichkeit, Prisma (oder jeden anderen ORM) direkt in einer Serverkomponente aufzurufen — ohne einen separaten API-Layer dazwischen.
In klassischen React-Architekturen baut man eine REST-API (oft in Node/Express, manchmal sogar in Rails), der React-Client ruft diese API über fetch auf, und man pflegt zwei Codebases. Mit Next.js Server Components entfällt diese Schicht für interne Datenzugriffe vollständig.
Ein vollständiges Beispiel, das nicht nur Notizen listet, sondern auch die Tags jeder Notiz mitlädt — das Äquivalent zu includes(:tags) in Rails:
// app/notes/page.tsx
import { prisma } from "@/lib/prisma";
export default async function NotesPage() {
// Wie Note.includes(:tags).order(created_at: :desc) in Rails
const notes = await prisma.note.findMany({
orderBy: { createdAt: "desc" },
include: {
tags: true,
user: { select: { name: true } },
},
});
return (
<main className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Alle Notizen</h1>
<ul className="space-y-4">
{notes.map((note) => (
<li key={note.id} className="border rounded-lg p-4">
<h2 className="font-semibold">{note.title}</h2>
<p className="text-sm text-gray-500">von {note.user.name}</p>
<div className="flex gap-2 mt-2">
{note.tags.map((tag) => (
<span
key={tag.id}
className="text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded"
>
{tag.name}
</span>
))}
</div>
</li>
))}
</ul>
</main>
);
}Wichtig: Der Prisma-Client darf nur in Server Components verwendet werden, nie in Client Components (Dateien mit "use client" am Anfang). Next.js stellt sicher, dass Server-only-Code nicht versehentlich gebundelt wird — aber als Konvention lohnt es sich, Datenbankzugriffe immer in eigene Hilfsfunktionen auszulagern, die klar als serverseitig markiert sind.
fetch() in Server Components — mit Caching
Neben dem direkten ORM-Zugriff kann man in Server Components natürlich auch externe APIs über fetch() aufrufen. Next.js erweitert die native fetch-API um eine Caching-Schicht, die sich über Optionen im next-Namespace steuern lässt.
// Daten werden gecacht und nach 60 Sekunden neu validiert
const response = await fetch("https://api.example.com/notes", {
next: { revalidate: 60 },
});
const data = await response.json();Die wichtigsten Caching-Strategien im Überblick:
// 1. Statisch gecacht (Standard bei fetch ohne Optionen in bestimmten Kontexten)
// — wird einmal gebaut und bleibt für die Laufzeit der Deployment-Instanz gültig
await fetch(url, { cache: "force-cache" });
// 2. Revalidate nach N Sekunden (Incremental Static Regeneration, kurz ISR)
// — ähnlich wie expires_in in Rails.cache
await fetch(url, { next: { revalidate: 300 } });
// 3. Kein Cache — immer frisch vom Server (ähnlich wie no-store in HTTP)
// — für Daten, die sich jede Anfrage ändern können
await fetch(url, { cache: "no-store" });Bei Prisma-Aufrufen greift dieses fetch-Caching nicht automatisch — Prisma kommuniziert über einen eigenen Datenbankkanal. Für ORM-Queries nutzt man stattdessen das Request-Caching von React mit cache() aus dem react-Paket, um dieselbe Query innerhalb eines Render-Passes nur einmal auszuführen.
generateStaticParams — SSG für dynamische Routen
Next.js kann Seiten zur Build-Zeit vorrendern, ähnlich wie statische Site-Generatoren (Jekyle, Eleventy) oder das prerender-Feature in neueren Rails-Versionen. Das ist besonders nützlich für Inhalte, die sich selten ändern, wie Blog-Posts, Dokumentationsseiten oder öffentliche Notizen.
Für dynamische Routen — also Seiten wie /notes/[id] — teilt generateStaticParams Next.js mit, welche IDs zur Build-Zeit vorgerendert werden sollen:
// app/notes/[id]/page.tsx
// Diese Funktion läuft nur beim Build (oder bei ISR-Revalidierung)
export async function generateStaticParams() {
const notes = await prisma.note.findMany({
select: { id: true },
});
// Next.js erwartet ein Array von Param-Objekten
return notes.map((note) => ({
id: String(note.id),
}));
}
// Die eigentliche Seite — wird für jede id statisch gebaut
export default async function NotePage({
params,
}: {
params: { id: string };
}) {
const note = await prisma.note.findUnique({
where: { id: Number(params.id) },
});
if (!note) return <div>Notiz nicht gefunden.</div>;
return <article>{note.title}</article>;
}In Rails entspricht das in etwa dem Einsatz von ActionController::Base.render in einem Rake-Task oder dem Einsatz von Frontailer und Static Site Generation mit Tools wie Bridgetown. Näher ist die Analogie zu config.x.prerender_paths oder dem manuellen Vorbefüllen eines HTTP-Caches per Crawler. generateStaticParams ist der explizite, deklarative Weg: du sagst Next.js genau, welche URLs existieren, und es rendert sie alle zur Build-Zeit.
Seiten, die nicht in generateStaticParams aufgelistet werden, werden entweder zur Laufzeit gerendert (Standardverhalten) oder mit einem 404 beantwortet — steuerbar über export const dynamicParams = false auf Seitenebene.
Paralleles Laden mit Promise.all
Manchmal braucht eine Seite Daten aus mehreren unabhängigen Quellen. Würde man diese nacheinander awaiten, entstünde ein sequenzieller Wasserfall: die zweite Query startet erst, wenn die erste abgeschlossen ist.
Die Lösung ist Promise.all — ein Standard-JavaScript-Pattern, das Next.js Server Components vollständig unterstützen:
// app/dashboard/page.tsx
import { prisma } from "@/lib/prisma";
export default async function DashboardPage() {
// Beide Queries starten gleichzeitig — keine sequenzielle Wartezeit
const [notes, tags] = await Promise.all([
prisma.note.findMany({
orderBy: { updatedAt: "desc" },
take: 5,
include: { user: { select: { name: true } } },
}),
prisma.tag.findMany({
orderBy: { name: "asc" },
}),
]);
return (
<div className="grid grid-cols-2 gap-8">
<section>
<h2 className="font-semibold mb-3">Neueste Notizen</h2>
<ul>
{notes.map((n) => <li key={n.id}>{n.title}</li>)}
</ul>
</section>
<section>
<h2 className="font-semibold mb-3">Alle Tags</h2>
<ul>
{tags.map((t) => <li key={t.id}>{t.name}</li>)}
</ul>
</section>
</div>
);
}In Rails entspricht das dem parallelen Laden mit pluck oder ActiveRecord::Base.connection.exec_query in Threads — in der Praxis macht man das selten, weil die meisten Rails-Apps Single-Thread-Worker nutzen. In Next.js ist das Muster idiomatisch und wird ausdrücklich empfohlen.
Ein Hinweis: Promise.allSettled ist die robustere Variante, wenn einzelne Fehler den Rest der Seite nicht blockieren sollen — jedes Promise wird unabhängig aufgelöst, und das Ergebnis enthält entweder { status: "fulfilled", value: ... } oder { status: "rejected", reason: ... }.
Fehlerbehandlung — try/catch und error.tsx
Datenbankfehler passieren: die Verbindung bricht ab, ein Record existiert nicht, eine Constraint schlägt fehl. Für Server Components gibt es zwei Ebenen der Fehlerbehandlung.
Innerhalb der Komponente — mit try/catch für lokale, kontrollierte Fehler:
export default async function NotePage({ params }: { params: { id: string } }) {
let note;
try {
note = await prisma.note.findUniqueOrThrow({
where: { id: Number(params.id) },
});
} catch {
// findUniqueOrThrow wirft, wenn nichts gefunden wird
return (
<div className="text-red-600 p-6">
Diese Notiz existiert nicht oder wurde gelöscht.
</div>
);
}
return <article>{note.title}</article>;
}Über error.tsx — ein spezielles Datei-Convention, das Next.js als Error Boundary für einen ganzen Routen-Zweig nutzt. Es fängt alle unbehandelten Fehler unterhalb seines Verzeichnisses ab:
// app/notes/error.tsx
"use client"; // Error Boundaries müssen Client Components sein
export default function NotesError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-6 border border-red-200 rounded-lg bg-red-50">
<h2 className="font-semibold text-red-800 mb-2">Etwas ist schiefgelaufen</h2>
<p className="text-red-600 text-sm mb-4">{error.message}</p>
<button
onClick={reset}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Nochmal versuchen
</button>
</div>
);
}Die Hierarchie ist dabei analog zu Rails' rescue_from in ApplicationController — error.tsx in einem Unterverzeichnis überschreibt das error.tsx weiter oben im Baum. Das digest-Attribut ist ein serverseitiger Fehler-Hash, über den man den Fehler in den Server-Logs zurückverfolgen kann, ohne sensible Details an den Client zu leaken.
Ladezustände — loading.tsx und Suspense
Wenn eine Serverkomponente auf Datenbankabfragen wartet, will man dem Nutzer Feedback geben. Next.js bietet dafür zwei Wege — beide wurden bereits in Modul 2 (Routing) und Modul 3 (Server Components) eingeführt, hier der Vollständigkeit halber in Kürze.
loading.tsx ist die einfachste Form: eine Datei im selben Verzeichnis wie page.tsx, die automatisch als Skeleton oder Spinner angezeigt wird, solange die Seite lädt. Sie ist technisch ein React Suspense Boundary, den Next.js für dich einrichtet:
// app/notes/loading.tsx
export default function NotesLoading() {
return (
<div className="space-y-4 animate-pulse">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 bg-slate-100 rounded-lg" />
))}
</div>
);
}<Suspense> gibt dir feingranulare Kontrolle für einzelne Komponenten innerhalb einer Seite — du kannst verschiedene Bereiche unabhängig voneinander laden lassen:
import { Suspense } from "react";
import NotesList from "@/components/NotesList";
import TagCloud from "@/components/TagCloud";
export default function Page() {
return (
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2">
{/* NotesList lädt unabhängig vom TagCloud */}
<Suspense fallback={<div>Notizen werden geladen…</div>}>
<NotesList />
</Suspense>
</div>
<aside>
<Suspense fallback={<div>Tags werden geladen…</div>}>
<TagCloud />
</Suspense>
</aside>
</div>
);
}Das Ergebnis ist eine Seite, die sofort etwas anzeigt und schrittweise befüllt wird — ohne dass der Nutzer auf den langsamsten Query warten muss, bevor irgendetwas sichtbar ist. Dieses Streaming-Verhalten ist einer der zentralen Vorteile des App Routers gegenüber dem alten Pages Router, der immer auf alle getServerSideProps-Aufrufe warten musste.
Öffne die Datei app/notes/page.tsx in deinem Projekt. Ändere das orderBy von createdAt: "desc" auf updatedAt: "desc", sodass zuletzt bearbeitete Notizen zuerst erscheinen.
// Vorher:
orderBy: { createdAt: "desc" }
// Nachher:
orderBy: { updatedAt: "desc" }Speichere die Datei und lade die /notes-Seite neu. Falls du Notizen hast, bei denen updatedAt und createdAt auseinanderliegen (zum Beispiel weil du eine Notiz bearbeitet hast), sollte sich die Reihenfolge jetzt ändern. Du kannst das überprüfen, indem du eine ältere Notiz bearbeitest und dann die Liste neu lädst — sie sollte an den Anfang wandern.
Bonus: Füge ein zweites Sortierkriterium als Tiebreaker hinzu, falls mehrere Notizen zum selben Zeitpunkt aktualisiert wurden:
orderBy: [{ updatedAt: "desc" }, { id: "desc" }]Im Capstone: DevNotes
Im Capstone-Projekt DevNotes wird das Datenfetching-Muster aus diesem Modul durchgängig eingesetzt. Die Detailseite einer Notiz unter /notes/[id] ist eine vollständige Server Component, die Prisma direkt aufruft — ohne einen separaten API-Endpunkt:
// app/notes/[id]/page.tsx
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
export default async function NoteDetailPage({
params,
}: {
params: { id: string };
}) {
const note = await prisma.note.findUnique({
where: { id: Number(params.id) },
include: {
tags: true,
user: { select: { name: true, email: true } },
},
});
// notFound() wirft intern einen Fehler, den Next.js
// mit der nächsten not-found.tsx abfängt
if (!note) notFound();
return (
<article className="max-w-2xl mx-auto p-6">
<header className="mb-6">
<h1 className="text-3xl font-bold">{note.title}</h1>
<p className="text-sm text-gray-500 mt-1">
von {note.user.name} · zuletzt aktualisiert{" "}
{new Date(note.updatedAt).toLocaleDateString("de-DE")}
</p>
<div className="flex gap-2 mt-3">
{note.tags.map((tag) => (
<span
key={tag.id}
className="text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full"
>
{tag.name}
</span>
))}
</div>
</header>
<div className="prose prose-slate max-w-none">
{note.content}
</div>
</article>
);
}Diese eine Datei ersetzt im Rails-Äquivalent den Controller (NotesController#show), die View (show.html.erb), sowie die Modell-Assoziation (Note.includes(:tags, :user)) — alles in einem einzigen, klar lesbaren async-Funktionsaufruf. Das ist der Kern des Paradigmenwechsels, den Next.js App Router mit Server Components eingeführt hat.