9.2 Next.js: Daten lesen, frisch statt eingefroren
Nach dieser Einheit kannst du:
- erklären, warum ein
GETohne Anfragedaten beim Build einfriert, und das mitconnection()verhindern. - eine Liste in einer Server-Komponente hinter
<Suspense>bei jeder Anfrage frisch laden. - begründet wählen, ob die Server-Komponente Daten direkt liest oder eine Client-Komponente sie per
fetchholt. - erklären, warum Seite und Route Handler nicht dieselbe Modul-Variable sehen, und den Speicher über
globalThisteilen.
Die API aus Einheit 9.1 läuft, curl zeigt drei Jobs. Dann zwei Überraschungen. Du baust das Projekt mit npm run build, startest es, und ein GET antwortet mit [], obwohl du gerade Jobs angelegt hast. Und die Seite /jobs, die dieselbe Funktion listJobs() aufruft wie die API, zeigt “No jobs yet”. Der Code ist beide Male richtig abgetippt. Falsch ist die Vorstellung davon, wann und wo er läuft.
Am Buffet wäre das so: Die Tafel mit dem Tagesangebot wird in der Früh einmal fotografiert, und alle schauen den ganzen Tag auf das Foto statt auf die Tafel. Und in der zweiten Küche hängt eine eigene Tafel, auf die niemand schreibt. Diese Einheit klärt beides: zuerst das Foto, dann die zwei Tafeln.
Begriffe vorab
Abschnitt betitelt „Begriffe vorab“- Vorrendern (Prerendering): Next.js führt Code schon beim Build aus und liefert später nur noch das gespeicherte Ergebnis (Einheit 8.2).
- Build-Zeit und Anfrage-Zeit: der Moment von
npm run buildund der Moment, in dem eine echte Anfrage eintrifft. - Modul-Variable: eine Variable, die in einer Datei außerhalb aller Funktionen steht, etwa
let jobsinlib/jobs.ts. globalThis: das eine Objekt, das in einem laufenden JavaScript-Programm überall dasselbe ist.
Der eingefrorene GET
Abschnitt betitelt „Der eingefrorene GET“Aus Einheit 8.2 weißt du: Mit Cache Components rendert Next.js beim Build vor, was es vorrendern kann. Das gilt auch für Route Handlers, allerdings nur für GET. Ein GET, der nichts aus der Anfrage liest, sieht für Next.js aus wie eine Antwort, die immer gleich ist. Also wird er beim Build einmal ausgeführt, und das Ergebnis wird für immer ausgeliefert:
import { filenames } from "@/lib/store";
// reads no request data: prerendered at build time, answers [] foreverexport async function GET() { return Response.json(filenames);}Beim Build ist die Liste leer, also antwortet dieser Endpunkt im Produktionsbetrieb für immer mit [], egal wie viele Einträge später dazukommen. Das ist das Foto der Tafel. Tückisch daran: Mit npm run dev fällt nichts auf, dort wird nichts eingefroren. Der Fehler zeigt sich erst nach npm run build und npm start. Die Abhilfe steht seit Einheit 9.1 in deinem Code:
import { connection } from "next/server";import { filenames } from "@/lib/store";
export async function GET() { await connection(); // wait for a real request return Response.json(filenames);}Beispielprojekt: 09_nextjs_api_datenfluss_examples/ex02_prerendered_get
await connection() sagt Next.js, dass diese Antwort auf eine echte Anfrage wartet. Der Build-Bericht zeigt den Unterschied mit den Symbolen aus Einheit 8.2: ○ für vorgerendert, ƒ für “läuft bei jeder Anfrage”.
flowchart TD
accTitle: Wann ein GET-Handler beim Build eingefroren wird
q1{"Methode GET?"} -- nein --> dyn["ƒ läuft bei jeder Anfrage"]
q1 -- ja --> q2{"Liest der Handler etwas<br/>aus der Anfrage oder ruft<br/>er connection auf?"}
q2 -- ja --> dyn
q2 -- nein --> froz["○ beim Build einmal ausgeführt,<br/>Antwort bleibt gleich"]
Abb. 9.4: Nur ein GET ohne Anfragedaten wird eingefroren (eigene Darstellung).
| Handler | Symbol im Build | Grund |
|---|---|---|
GET ohne Anfragedaten | ○ | sieht aus wie eine feste Antwort |
GET mit await connection() | ƒ | wartet ausdrücklich auf eine Anfrage |
GET mit request.headers oder await params | ƒ | braucht Daten aus der Anfrage |
POST, PATCH, DELETE | ƒ | werden nie vorgerendert |
Auflösung
(a) ○, und das passt: Die Version ändert sich erst mit dem nächsten Build. (b) ƒ, weil der Handler einen Header liest; die Antwort ist aktuell. (c) ○, und das ist ein Fehler: Das Angebot ist für immer das vom Build. Mit await connection() am Anfang wird daraus ƒ.
Daten lesen: zwei Wege zum Client
Abschnitt betitelt „Daten lesen: zwei Wege zum Client“Für die Anzeige der Jobs gibt es zwei berechtigte Wege, und die Wahl ist eine Architekturentscheidung.
Weg 1: Die Server-Komponente liest direkt. Die Seite läuft ohnehin am Server. Sie fragt die Datenquelle selbst, ohne Umweg über HTTP:
import { connection } from "next/server";import { Suspense } from "react";import { JobTable } from "@/app/job-table";import { listJobs } from "@/lib/jobs"; // same function the API uses
async function CurrentJobs() { await connection(); // the list changes at runtime return <JobTable jobs={listJobs()} />;}
export default function JobsPage() { return ( <Suspense fallback={<p>loading jobs ...</p>}> <CurrentJobs /> </Suspense> );}Das ist das Muster aus Einheit 8.2: Was bei jeder Anfrage frisch sein muss, wartet mit connection() und steht hinter <Suspense>. Lässt du beides weg, friert die Seite genauso ein wie der GET oben: Der Build markiert sie mit ○, und sie zeigt für immer “No jobs yet”. Mit connection() und <Suspense> steht dort ◐.
Voraussetzung für Weg 1 ist, dass die Logik (hier listJobs) in einem eigenen Modul liegt, das Route Handler und Seite importieren. Die Seite ruft also nicht ihre eigene API per fetch auf; das wäre eine zusätzliche Anfrage vom Server an sich selbst. Diese Auslagerung ist der erste Schritt in Richtung Schichten, den Kapitel 10 systematisch geht.
Weg 2: Die Client-Komponente holt per fetch. Ansichten, die sich ohne Seitenwechsel aktualisieren (Suchfeld, automatisches Nachladen), laden im Browser über die eigene API, mit useEffect wie in Einheit 7.6:
"use client";
import { useEffect, useState } from "react";import { JobTable } from "@/app/job-table";import type { Job } from "@/lib/types";
export function LiveJobList() { const [jobs, setJobs] = useState<Job[]>([]);
useEffect(() => { const timer = setInterval(async () => { const response = await fetch("/api/jobs"); if (response.ok) setJobs(await response.json()); }, 5000); return () => clearInterval(timer); }, []);
return <JobTable jobs={jobs} />;}Beispielprojekt: 09_nextjs_api_datenfluss_examples/ex03_reading_data
flowchart LR
accTitle: Zwei Wege von den Daten zum Browser
subgraph w1["Weg 1: Server-Komponente"]
direction LR
b1["Browser"] -- "Seite /jobs" --> sc["JobsPage<br/>am Server"]
sc -- "listJobs()" --> l1["lib/jobs.ts"]
end
subgraph w2["Weg 2: Client-Komponente"]
direction LR
b2["LiveJobList<br/>im Browser"] -- "fetch /api/jobs" --> rh["Route Handler"]
rh -- "listJobs()" --> l2["lib/jobs.ts"]
end
Abb. 9.5: Die Server-Komponente fragt das Modul direkt, die Client-Komponente geht über die API (eigene Darstellung).
| Weg 1: Server-Komponente | Weg 2: Client-Komponente | |
|---|---|---|
| Daten kommen | fertig im HTML der Seite | nach dem Laden, per fetch |
| gut für | die erste Anzeige: schnell, ohne Ladeflackern, für Suchmaschinen lesbar | Aktualisierung und Interaktion ohne Seitenwechsel |
| braucht die API | nein | ja |
Faustregel: Erstanzeige über die Server-Komponente, Aktualisierung und Interaktion über den Client. Die eigene API ist außerdem der einzige Weg für alles, was von außerhalb des Projekts zugreift. Sie ist der Schalter für alle; die Server-Komponente nimmt die Abkürzung hinter der Theke.
Wo begegnet dir das? Eine Sportseite liefert die Tabelle fertig im HTML aus (Weg 1) und lässt den Live-Ticker daneben alle paar Sekunden nachladen (Weg 2). Beides auf derselben Seite, jedes dort, wo es passt.
Auflösung
(a) Weg 1: Die Daten stehen sofort im HTML, niemand sieht eine leere Seite. (b) Weg 2: Die Ansicht ändert sich ohne Seitenwechsel, das kann nur der Browser. (c) Keiner der beiden: Die App liegt außerhalb des Projekts und spricht direkt mit der API, mit GET /api/items. Genau dafür gibt es den Vertrag.
Zwei Kopien desselben Moduls
Abschnitt betitelt „Zwei Kopien desselben Moduls“Jetzt zur zweiten Überraschung. Mit dem Speicher aus Einheit 9.1 legst du über die API einen Job an. GET /api/jobs zeigt ihn, die Seite /jobs zeigt “No jobs yet”. Beide rufen listJobs() aus derselben Datei auf.
Der Grund: Next.js bündelt Seiten und Route Handlers getrennt. Die Seite und die API bekommen jeweils eine eigene Kopie von lib/jobs.ts, und jede Kopie hat ihre eigene Variable jobs. Das sind die zwei Tafeln in zwei Küchen: Die API schreibt auf die eine, die Seite liest von der anderen. Das gilt mit npm run dev genauso wie nach dem Build.
flowchart TB
accTitle: Seite und Route Handler mit getrenntem und mit geteiltem Speicher
subgraph before["Modul-Variable: zwei Kopien"]
direction LR
rh1["Route Handler"] --> m1["lib/jobs.ts<br/>jobs: 1 Eintrag"]
pg1["Seite /jobs"] --> m2["lib/jobs.ts<br/>jobs: leer"]
end
subgraph after["globalThis: ein Speicher"]
direction LR
rh2["Route Handler"] --> g["globalThis.jobStore<br/>jobs: 1 Eintrag"]
pg2["Seite /jobs"] --> g
end
Abb. 9.6: Zwei Kopien des Moduls haben zwei Listen; globalThis gibt es im Serverprozess nur einmal (eigene Darstellung).
Solange die Daten nur im Arbeitsspeicher liegen, hilft ein Ort, den es im Serverprozess garantiert nur einmal gibt: globalThis. Der Speicher zieht dorthin um, die Funktionen bleiben nach außen gleich:
// lib/jobs.ts: one store for pages and route handlersimport type { Job, NewJob } from "./types";
interface JobStore { jobs: Job[]; nextId: number;}
// pages and route handlers each load their own copy of this module,// but globalThis exists only once in the server processconst memory = globalThis as { jobStore?: JobStore };memory.jobStore ??= { jobs: [], nextId: 1 };const store = memory.jobStore;
export function listJobs(): Job[] { return store.jobs;}Beispielprojekt: 09_nextjs_api_datenfluss_examples/ex03_reading_data
??= weist nur zu, wenn links noch undefined steht: Die erste Kopie legt den Speicher an, jede weitere findet ihn vor. addJob und die anderen Funktionen arbeiten ab jetzt mit store.jobs und store.nextId.
Das ist eine Krücke, und sie darf so heißen. Sie löst das Problem nur für eine Liste im Arbeitsspeicher eines einzigen Servers. Sobald die Daten in einer Datenbank liegen (Kapitel 11), fragen Seite und API dieselbe Datenbank, und die Frage nach den Kopien stellt sich nicht mehr.
Stolperfallen
Abschnitt betitelt „Stolperfallen“- Nur mit
npm run devgetestet: Eingefrorene Antworten zeigen sich erst nachnpm run buildundnpm start; der Build-Bericht mit○,◐undƒgehört zur Abnahme. GETohneconnection(): Der Endpunkt liefert für immer die Antwort vom Build, bei einer Liste im Arbeitsspeicher also[].- Seite liest ohne
connection()und<Suspense>: Auch sie wird einmal beim Build gerendert und zeigt danach immer denselben Stand. - Server-Komponente ruft die eigene API auf:
fetch("/api/jobs")in einer Server-Komponente ist eine überflüssige Anfrage des Servers an sich selbst; die Funktion direkt aufzurufen ist kürzer und schneller. - Modul-Variable als gemeinsamer Speicher: Die API schreibt, die Seite sieht nichts, weil beide eine eigene Kopie des Moduls haben.
useEffectohne Aufräumen: OhneclearIntervalin der Rückgabefunktion läuft der Timer weiter, wenn die Komponente längst verschwunden ist (Einheit 7.6).
Zusatzmaterialien
Abschnitt betitelt „Zusatzmaterialien“- Next.js: Route Handlers - der Abschnitt zu Cache Components erklärt, wann ein
GETvorgerendert wird. - Next.js: Fetching Data - Daten in Server- und Client-Komponenten laden.
- Next.js: Caching - statische Hülle,
"use cache"und<Suspense>mit Cache Components. - MDN: globalThis - das globale Objekt in Browser und Node.js (englisch).
Zum Anhören
Abschnitt betitelt „Zum Anhören“Du kannst dir diese Einheit kostenlos mit der Vorlesefunktion deines Browsers oder Betriebssystems vorlesen lassen (etwa „Laut vorlesen” in Edge), ohne Konto und ohne Upload; die Codebeispiele und Diagramme liest du dabei am Bildschirm mit. Freiwillig kannst du mit Google NotebookLM (Google-Konto nötig) aus diesem öffentlichen Text eine gesprochene Zusammenfassung erzeugen, auch auf Deutsch.
Lernergebnisse
Abschnitt betitelt „Lernergebnisse“Du kannst jetzt:
- erklären, warum ein
GETohne Anfragedaten beim Build einfriert, und das mitconnection()verhindern. - eine Liste in einer Server-Komponente hinter
<Suspense>bei jeder Anfrage frisch laden. - begründet wählen, ob die Server-Komponente Daten direkt liest oder eine Client-Komponente sie per
fetchholt. - erklären, warum Seite und Route Handler nicht dieselbe Modul-Variable sehen, und den Speicher über
globalThisteilen.
Passende Übungen
Abschnitt betitelt „Passende Übungen“- Aufgabe 09-1a - Die eigene API, PlusPlus Aufgabe (Abnahme gegen den Build)
- Aufgabe 09-2a - Client trifft Server, Stufe Wesentlich_überwiegend (Angebot anzeigen)
Quellen
Abschnitt betitelt „Quellen“- Vorrendern von
GET-Handlern mit Cache Components, Auslöser für das Rendern pro Anfrage → Next.js: Route Handlers;connection()→ Next.js: connection - Eingefrorener
GET(○im Build, Antwort[]nachnpm start, aktuelle Antwort mitnpm run dev),ƒbeirequest.headersundawait params, Seite ohneconnection()als○mit “No jobs yet”, mitconnection()und<Suspense>als◐→ eigener Test mit Next.js 16.4.0 und Cache Components (Oktober 2026) - Daten in Server- und Client-Komponenten → Next.js: Fetching Data; keine Route Handlers aus Server-Komponenten aufrufen → Next.js: Production Checklist
- Getrennte Kopien eines Moduls für Seite und Route Handler (mit
npm run devund nach dem Build), gemeinsamer Speicher überglobalThis→ eigener Test mit Next.js 16.4.0 (Oktober 2026);globalThisund??=→ MDN: globalThis, Nullish coalescing assignment - Abb. 9.4 (eingefrorener GET), Abb. 9.5 (zwei Wege), Abb. 9.6 (zwei Kopien, ein Speicher) → eigene Darstellung, alle als Mermaid-Diagramm
Autoren
Abschnitt betitelt „Autoren“- Oliver Maurhart (MAO)
- Michael Gabriel (GAM)
CC-BY-SA