Caching und Revalidation

Caching ist eine jener Techniken, die in der Entwicklung oft stiefmütterlich behandelt werden — bis die Anwendung in Produktion läuft und man sich fragt, warum manche Daten stundenlang veraltet bleiben oder warum eine Seite bei jedem Request neu vom Datenbankserver gerendert wird. In Next.js ist Caching kein nachträglicher Gedanke, sondern tief in die Runtime integriert. Das Modell ist mächtiger als Rails' Cache-Infrastruktur, aber auch komplexer — und genau deshalb lohnt es sich, es systematisch zu verstehen.

Caching in Next.js vs. Rails

Rails-Entwickler kennen Caching primär durch Rails.cache: ein einfaches Key-Value-Store-Interface, das wahlweise mit Memcached, Redis oder dem FileStore betrieben wird. Man schreibt einen Wert hinein, liest ihn heraus, und entscheidet selbst, wann er ungültig wird. Das ist explizit und gut verständlich — aber es liegt in der Verantwortung des Entwicklers, jeden Cache-Aufruf manuell zu platzieren.

# Rails: explizites Caching mit Rails.cache
notes = Rails.cache.fetch("user_#{user_id}_notes", expires_in: 1.hour) do
  Note.where(user_id: user_id).to_a
end

Next.js geht einen anderen Weg: Caching ist standardmäßig aktiv. Wenn man eine Funktion mit fetch() oder mit der use cache-Direktive schreibt, wird das Ergebnis automatisch gecacht — ohne dass man explizit einen Key vergeben oder einen Store konfigurieren muss. Das klingt nach einem Gewinn, bringt aber eine neue Herausforderung mit sich: Man muss verstehen, wann und wie Next.js cached, um unerwartetes Verhalten zu vermeiden.

Der entscheidende Unterschied liegt im Scope: Rails.cache ist ein globaler Application-Cache. Next.js hingegen hat mehrere Caching-Schichten mit unterschiedlichem Lebenszyklus, und jede davon greift an einem anderen Punkt in der Request-Verarbeitung.

Das Next.js Caching-Modell

Next.js besitzt vier Caching-Schichten, die übereinander gestapelt sind. Es ist hilfreich, sie von innen nach außen zu denken — von der einzelnen Datenbankabfrage bis zum fertigen HTML, das an den Browser ausgeliefert wird.

1. Request Memoization

Die innerste Schicht. Wenn während eines einzelnen Server-Requests dieselbe fetch()-URL oder dieselbe gecachte Funktion mehrfach aufgerufen wird, wird der tatsächliche Aufruf nur einmal ausgeführt. Das Ergebnis wird für die Dauer dieses einen Requests im Speicher gehalten.

Typischer Anwendungsfall: Ein Layout und eine Page-Komponente fragen beide die aktuell eingeloggte Nutzerin ab. Ohne Memoization würden zwei identische Datenbankaufrufe entstehen. Mit Memoization passiert der Aufruf einmal, das Ergebnis wird für beide wiederverwendet.

Diese Schicht ist automatisch und unveränderbar — man kann sie nicht deaktivieren, muss aber auch nichts konfigurieren.

2. Data Cache

Der persistente Server-Cache für Datenabrufe. Ergebnisse von fetch()-Aufrufen oder von Funktionen, die mit use cache markiert sind, werden hier gespeichert — auch über mehrere Requests und Deployments hinweg (abhängig von der Runtime).

Typischer Anwendungsfall: Eine Funktion, die alle öffentlichen Notizen lädt, deren Inhalt sich selten ändert. Der Data Cache vermeidet wiederholte Datenbankabfragen für jede einzelne Seitenansicht.

3. Full Route Cache

Wenn eine Route vollständig statisch gerendert werden kann (d.h. alle ihre Daten sind gecacht und es gibt keine dynamischen Ausdrücke wie cookies() oder headers()), speichert Next.js das fertige HTML und die RSC-Payload der gesamten Route. Bei einem Request wird das vorgerenderte Ergebnis direkt ausgeliefert, ohne dass React oder die Datenbank überhaupt involviert werden.

Typischer Anwendungsfall: Eine öffentliche Blog-Seite, ein Landing-Page-Segment, eine statische About-Seite.

4. Router Cache

Die vierte Schicht lebt im Browser. Wenn der Nutzer zwischen Seiten navigiert, speichert Next.js bereits besuchte (oder per <Link> vorgefetchte) Route-Segmente im Client-Memory. Navigation innerhalb der Anwendung ist dadurch sofort — kein Netzwerk-Request notwendig.

Wichtig für Rails-Entwickler: Dieser Cache ist für SSR-Seiten, die nach einer Mutation aktualisiert werden müssen, eine häufige Fehlerquelle. Wenn revalidatePath() auf dem Server aufgerufen wird, invalidiert das nur den Full Route Cache und den Data Cache — der Router Cache im Browser wird nach einem kurzen Zeitfenster (Standard: 30 Sekunden für dynamische Routen) automatisch geleert, oder sofort wenn der Nutzer manuell navigiert. Alternativ erzwingt router.refresh() auf dem Client eine sofortige Auffrischung.

use cache — Funktionen und Komponenten als gecacht markieren

Ab Next.js 15 (mit dem "dynamicIO"-Flag, das in 16 Standard wird) ist use cache die primäre Art, Datenabrufe zu cachen. Die Direktive funktioniert analog zu "use client" oder "use server": man schreibt sie an den Anfang einer Funktion oder einer Datei, und Next.js behandelt diese Funktion als Cache-Boundary.

// lib/notes.ts
import { unstable_cacheTag as cacheTag, unstable_cacheLife as cacheLife } from "next/cache";

export async function getAllPublicNotes() {
  "use cache";
  // Diese Funktion wird gecacht — der Datenbankaufruf passiert nur beim ersten Aufruf
  // (bzw. nach Ablauf der Cache-Lebenszeit oder nach expliziter Invalidierung).
  const notes = await db.note.findMany({
    where: { public: true },
    orderBy: { createdAt: "desc" },
  });
  return notes;
}

Der Unterschied zu fetch() mit Cache-Optionen: use cache funktioniert mit beliebigen asynchronen Funktionen — Datenbankabfragen via Prisma, externe API-Calls mit komplexer Logik, berechnete Aggregationen. Man ist nicht auf den fetch-API-Wrapper angewiesen.

Wenn use cache in einer Datei ohne weitere Konfiguration verwendet wird, gelten die Default-Einstellungen der Runtime: üblicherweise ein persistenter Cache ohne automatisches Ablaufen.

cacheLife — Ablaufzeiten festlegen

Gecachte Daten bleiben ohne explizite Konfiguration dauerhaft gültig, bis sie manuell invalidiert werden. Für Daten, die sich regelmäßig ändern — etwa eine Statistik, ein Aktivitäts-Feed oder externe API-Daten — lässt sich mit cacheLife eine Ablaufzeit definieren.

import {
  unstable_cacheLife as cacheLife,
  unstable_cacheTag as cacheTag,
} from "next/cache";

export async function getDashboardStats() {
  "use cache";
  // Kurzform: benannte Profile ("seconds", "minutes", "hours", "days", "weeks", "max")
  cacheLife("hours");

  const stats = await db.note.aggregate({
    _count: { id: true },
    _avg: { wordCount: true },
  });
  return stats;
}

export async function getRecentActivity() {
  "use cache";
  // Langform: granulare Kontrolle über stale, revalidate und expire
  cacheLife({
    stale: 30,        // Browser darf 30 Sekunden alten Cache ohne Revalidierung ausliefern
    revalidate: 300,  // Nach 5 Minuten wird der Data Cache im Hintergrund erneuert
    expire: 3600,     // Nach 1 Stunde wird der Eintrag vollständig entfernt
  });

  const activity = await db.note.findMany({
    orderBy: { updatedAt: "desc" },
    take: 20,
  });
  return activity;
}

Das stale-Feld entspricht konzeptuell dem stale-while-revalidate-Header aus HTTP-Caching: der Cache gilt als "alt genug, um im Hintergrund neu geladen zu werden", aber der Nutzer bekommt trotzdem sofort eine Antwort. Das revalidate-Feld ist der Zeitraum, nach dem Next.js eine Neuberechnung anstößt. expire ist die harte Grenze, nach der der Eintrag aus dem Cache gelöscht wird.

cacheTag + revalidateTag — Gezieltes Invalidieren

Die mächtigste Kombination im Next.js Caching-System: Man versieht gecachte Daten mit einem oder mehreren Tags und kann diese Tags später gezielt invalidieren — zum Beispiel in einer Server Action nach einer Mutation.

// lib/notes.ts
import {
  unstable_cacheTag as cacheTag,
  unstable_cacheLife as cacheLife,
} from "next/cache";

export async function getNotesForUser(userId: string) {
  "use cache";
  cacheLife("minutes");
  // Tag mit der User-ID: nur dieser Nutzer's Cache wird invalidiert
  cacheTag(`user-notes-${userId}`);
  // Globaler Tag für alle Notizen (z.B. für Admin-Ansichten)
  cacheTag("all-notes");

  return db.note.findMany({
    where: { userId },
    orderBy: { createdAt: "desc" },
  });
}
// app/notes/actions.ts
"use server";

import { revalidateTag } from "next/cache";
import { getCurrentUser } from "@/lib/auth";
import { db } from "@/lib/db";

export async function createNoteAction(formData: FormData) {
  const user = await getCurrentUser();
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  await db.note.create({
    data: { title, content, userId: user.id },
  });

  // Invalidiert alle Cache-Einträge, die mit diesem Tag versehen wurden.
  // Das nächste Mal, wenn getNotesForUser() aufgerufen wird, geht es an die DB.
  revalidateTag(`user-notes-${user.id}`);
}
Rails-Analogie
Ruby on Rails
# Rails: alle Cache-Einträge mit Pattern löschen
Rails.cache.delete_matched("user_#{user_id}_notes*")

# Oder mit explizitem Key:
Rails.cache.delete("user_#{user_id}_notes")
Next.js
// Next.js: alle mit dem Tag versehenen Einträge invalidieren
import { revalidateTag } from "next/cache";

revalidateTag(`user-notes-${userId}`);
// Trifft alle Cache-Einträge, die cacheTag(`user-notes-${userId}`)
// aufgerufen haben — egal wie viele Funktionen das sind.

Der wichtigste Unterschied zu Rails.cache.delete_matched: Bei revalidateTag pflegt der Entwickler keine expliziten Cache-Keys — Next.js verwaltet die Zuordnung von Tags zu Cache-Einträgen intern. Man muss sich nicht merken, welche Keys mit welchem Muster erzeugt wurden. Man denkt stattdessen in Domänen-Begriffen: "alle Notizen dieses Nutzers", "alle öffentlichen Posts", "Konfigurationsdaten".

revalidatePath — Route-Cache nach Mutations invalidieren

revalidatePath ist der grobkörnigere Bruder von revalidateTag: statt einzelner Datenobjekte wird der Cache einer gesamten Route-URL invalidiert. Das bedeutet, beim nächsten Request wird die Seite komplett neu gerendert — als wäre sie nie gecacht worden.

// app/notes/actions.ts
"use server";

import { revalidatePath } from "next/cache";

export async function deleteNoteAction(noteId: string) {
  await db.note.delete({ where: { id: noteId } });

  // Die /notes-Seite wird beim nächsten Aufruf frisch gerendert
  revalidatePath("/notes");

  // Optional: alle Unterrouten ebenfalls invalidieren
  revalidatePath("/notes", "layout");
}

In Modul 6 haben wir revalidatePath bereits in Server Actions nach dem Erstellen und Löschen von Notizen verwendet. Dort war es die einfachste Lösung — nach jeder Mutation wird die Liste neu gerendert. Für feingranularere Kontrolle (z.B. wenn mehrere Routen auf dieselben Daten zugreifen) ist revalidateTag die bessere Wahl, weil man die Invalidierung in der Datenschicht bündeln kann, anstatt URL-Strings durch die ganze Codebasis zu streuen.

Zeitbasierte Revalidierung (ISR) mit fetch

Für Daten, die über HTTP-fetch abgerufen werden, bietet Next.js eine einfachere Alternative zu use cache + cacheLife: die next.revalidate-Option direkt im fetch-Aufruf. Das ist die klassische Incremental Static Regeneration (ISR) aus Next.js, nun auch mit dem neuen Cache-Modell kompatibel.

// lib/external.ts
export async function getGitHubStats(repo: string) {
  const response = await fetch(
    `https://api.github.com/repos/${repo}`,
    {
      next: {
        revalidate: 3600, // Jede Stunde neu von GitHub laden
        tags: ["github-stats"],
      },
    }
  );
  if (!response.ok) throw new Error("GitHub API error");
  return response.json();
}
Rails-Analogie

Wer Rails mit einem Static Site Generator wie Jekyll kombiniert hat, kennt das Konzept: Seiten werden einmal gebaut und ausgeliefert, bis ein Rebuild ausgelöst wird. ISR in Next.js ist ähnlich — Seiten werden bei der ersten Anfrage gerendert und gecacht, danach im Hintergrund nach Ablauf von revalidate-Sekunden erneuert. Der Unterschied: kein manueller Build-Trigger, Next.js steuert den Zeitplan automatisch. In reinem Rails entspricht das Rails.cache.fetch("key", expires_in: 1.hour) — der Cache läuft ab, beim nächsten Miss wird neu geladen.

ISR ist besonders nützlich für externe API-Daten, bei denen man keinen direkten Einfluss auf Mutations hat — man kann nicht wissen, wann sich GitHub-Daten ändern, aber man akzeptiert, dass die Daten bis zu einer Stunde veraltet sein dürfen.

Caching abschalten — force-dynamic und cache: "no-store"

Manchmal soll eine Route oder eine Funktion bewusst niemals gecacht werden: Live-Daten, personalisierte Inhalte, Seiten, die den aktuellen Authentifizierungsstatus des Nutzers zeigen.

Für eine gesamte Route verwendet man das dynamic-Export in der Page-Datei:

// app/dashboard/page.tsx
// Diese Route wird bei jedem Request neu gerendert — nie gecacht.
export const dynamic = "force-dynamic";

export default async function DashboardPage() {
  const user = await getCurrentUser();
  const notes = await db.note.findMany({ where: { userId: user.id } });
  return <Dashboard notes={notes} />;
}

Für einen einzelnen fetch-Aufruf gibt es cache: "no-store":

// Dieser Aufruf wird nie gecacht — jeder Request geht ans Netzwerk.
const livePrice = await fetch("https://api.example.com/price/BTC", {
  cache: "no-store",
});

Wann ist force-dynamic sinnvoll? Immer dann, wenn die Seite von Daten abhängt, die sich zwischen Requests ändern und von denen man nicht weiß, wann sich das ändert — etwa eine Admin-Oberfläche mit Echtzeit-Metriken, ein Chat-Interface, oder eine Seite, die Cookie-basierte Personalisierung erfordert. In der DevNotes-App ist die /notes-Seite ein Grenzfall: Die Daten sind nutzergebunden und ändern sich mit jeder neuen Notiz. Hier ist revalidateTag nach Mutations eleganter als dauerhaftes force-dynamic, weil der Cache zwischen Mutations genutzt werden kann.

cacheTag und revalidateTag in DevNotes

Erweitere die DevNotes-App um granulares Caching:

  1. getNotesForUser mit use cache und cacheTag versehen: Öffne die Funktion, die Notizen aus der Datenbank lädt. Füge "use cache" und einen cacheTag-Aufruf mit der User-ID hinzu. Wähle ein konsistentes Tag-Format, z.B. `notes:${userId}`.

  2. revalidateTag in createNoteAction aufrufen: Nach dem Erstellen einer neuen Notiz soll der Cache für die aktuelle Nutzerin invalidiert werden. Ersetze (oder ergänze) den revalidatePath-Aufruf durch revalidateTag.

  3. Gleiches für deleteNoteAction und updateNoteAction: Jede Mutation soll den nutzergebundenen Cache ungültig machen.

  4. Verhalten prüfen: Starte die Anwendung im Production-Mode (npm run build && npm start). Lade die Notizliste, lege eine neue Notiz an. Erscheint die neue Notiz sofort? Prüfe den Output im Terminal — siehst du Cache-HIT- und Cache-MISS-Meldungen?

Bonus: Füge einem öffentlichen Notizen-Endpunkt den Tag "public-notes" hinzu und invalidiere diesen Tag bei jeder neuen öffentlichen Notiz.

Im Capstone: DevNotes

Im Development-Modus (npm run dev) ist das Caching in Next.js weitgehend deaktiviert — jede Funktion mit "use cache" wird tatsächlich bei jedem Request neu ausgeführt. Das ist bewusst so gestaltet: Während der Entwicklung will man immer den aktuellen Stand sehen, nicht einen veralteten Cache-Eintrag debuggen müssen.

Die vollen Cache-Effekte zeigen sich erst im Production-Build (npm run build && npm start). Erst dort wird der Data Cache persistent beschrieben, werden Routen als statisch klassifiziert und gecacht, und lässt sich beobachten, wie revalidateTag den Cache selektiv leert, ohne die gesamte Route neu zu rendern.

Für DevNotes bedeutet das konkret: Die /notes-Route ist per se dynamisch (nutzergebunden, authentifiziert), sie profitiert also nicht vom Full Route Cache. Der Data Cache ist aber auch hier nützlich — zwischen zwei Mutations müssen die Notizen nicht bei jedem Tab-Wechsel neu aus der Datenbank geladen werden. Mit cacheTag pro Nutzer lässt sich ein sauberes Modell aufbauen: Der Cache ist gültig, bis der Nutzer selbst eine Mutation auslöst. Das entspricht genau dem mentalen Modell, das Rails-Entwickler aus Rails.cache.fetch kennen — nur dass Next.js die Bookkeeping-Arbeit übernimmt.

In den nächsten Modulen werden wir sehen, wie Middleware und Edge-Deployment das Caching-Modell weiter beeinflussen — und warum eine sorgfältig aufgebaute Cache-Strategie der wichtigste Performance-Hebel in einer Next.js-Anwendung ist.