Zum Inhalt springen

9.2 Next.js: Daten lesen, frisch statt eingefroren

Zu Zen-Modus wechseln

Nach dieser Einheit kannst du:

  • erklären, warum ein GET ohne Anfragedaten beim Build einfriert, und das mit connection() 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 fetch holt.
  • erklären, warum Seite und Route Handler nicht dieselbe Modul-Variable sehen, und den Speicher über globalThis teilen.

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.

  • 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 build und der Moment, in dem eine echte Anfrage eintrifft.
  • Modul-Variable: eine Variable, die in einer Datei außerhalb aller Funktionen steht, etwa let jobs in lib/jobs.ts.
  • globalThis: das eine Objekt, das in einem laufenden JavaScript-Programm überall dasselbe ist.

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:

app/api/frozen-list/route.ts
import { filenames } from "@/lib/store";
// reads no request data: prerendered at build time, answers [] forever
export 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:

app/api/list/route.ts
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).

HandlerSymbol im BuildGrund
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 ƒ.

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:

app/jobs/page.tsx
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-KomponenteWeg 2: Client-Komponente
Daten kommenfertig im HTML der Seitenach dem Laden, per fetch
gut fürdie erste Anzeige: schnell, ohne Ladeflackern, für Suchmaschinen lesbarAktualisierung und Interaktion ohne Seitenwechsel
braucht die APIneinja

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.

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 handlers
import 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 process
const 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.

  1. Nur mit npm run dev getestet: Eingefrorene Antworten zeigen sich erst nach npm run build und npm start; der Build-Bericht mit ○, ◐ und ƒ gehört zur Abnahme.
  2. GET ohne connection(): Der Endpunkt liefert für immer die Antwort vom Build, bei einer Liste im Arbeitsspeicher also [].
  3. Seite liest ohne connection() und <Suspense>: Auch sie wird einmal beim Build gerendert und zeigt danach immer denselben Stand.
  4. 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.
  5. Modul-Variable als gemeinsamer Speicher: Die API schreibt, die Seite sieht nichts, weil beide eine eigene Kopie des Moduls haben.
  6. useEffect ohne Aufräumen: Ohne clearInterval in der Rückgabefunktion läuft der Timer weiter, wenn die Komponente längst verschwunden ist (Einheit 7.6).
  • Next.js: Route Handlers - der Abschnitt zu Cache Components erklärt, wann ein GET vorgerendert 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).

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.

Du kannst jetzt:

  • erklären, warum ein GET ohne Anfragedaten beim Build einfriert, und das mit connection() 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 fetch holt.
  • erklären, warum Seite und Route Handler nicht dieselbe Modul-Variable sehen, und den Speicher über globalThis teilen.
  • Aufgabe 09-1a - Die eigene API, PlusPlus Aufgabe (Abnahme gegen den Build)
  • Aufgabe 09-2a - Client trifft Server, Stufe Wesentlich_überwiegend (Angebot anzeigen)
  • 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 [] nach npm start, aktuelle Antwort mit npm run dev), ƒ bei request.headers und await params, Seite ohne connection() als ○ mit “No jobs yet”, mit connection() 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 dev und nach dem Build), gemeinsamer Speicher über globalThis → eigener Test mit Next.js 16.4.0 (Oktober 2026); globalThis und ??= → 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
  • Oliver Maurhart (MAO)
  • Michael Gabriel (GAM)

CC-BY-SA

https://qiz.at/rules