11. Juli 2026 · Piotr Józef Schumann
Cache Components und PPR in Next.js 16: was sich geändert hat und wie ich es nutze
Ein paar Jahre lang war die Caching-Story von Next.js ein Haufen sich überschneidender experimenteller Flags — experimental.ppr, experimental.useCache, experimental.dynamicIO — dazu Route-Segment-Konfigurationen wie export const revalidate und die Funktion unstable_cache. Man konnte es zum Laufen bringen, aber einem Junior-Dev beim Kunden zu erklären, warum es funktionierte, war schmerzhaft.
Next.js 16 fasst das alles in einem einzigen Flag zusammen: cacheComponents. Das ist das mentale Modell, das ich mir früher gewünscht hätte, und so setze ich es tatsächlich auf echten Sites ein.
Die eine Idee: standardmäßig dynamisch, gezielt cachen
Mit aktivierten Cache Components ist Data Fetching standardmäßig dynamisch. Nichts wird gecacht, solange du es nicht ausdrücklich sagst. Einzelne Seiten, Komponenten oder Funktionen aktivierst du dann gezielt fürs Caching mit der use cache-Directive.
Next.js prerendert eine statische HTML-Shell, liefert sie sofort aus und streamt anschließend die dynamischen Teile hinein, sobald sie bereit sind. Statisches und Dynamisches innerhalb einer einzigen Route zu mischen — das ist Partial Prerendering (PPR), und in Next 16 ist es einfach das Standardverhalten von Cache Components, kein separates Flag, das du umlegst.
Das Modell besteht also aus zwei Fragen pro UI-Baustein:
- Kann das für eine Weile für alle gleich sein? → cachen, es landet in der statischen Shell.
- Ist es pro Request oder pro User? → dynamisch lassen, es streamt in ein Loch hinein.
Einschalten
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
Dieses eine Flag ersetzt die alten experimental.ppr, experimental.useCache und experimental.dynamicIO. Falls du eines davon genutzt hast, beschreibt der Upgrade-Guide zu Next 16 den Migrationspfad.
use cache — drei Ebenen
Die Directive funktioniert auf Datei-, Komponenten- oder Funktionsebene:
// Ganze Datei gecacht
"use cache";
export default async function Page() {
// ...
}
// Einzelne Komponente gecacht
export async function PriceList() {
"use cache";
const prices = await getPrices();
return <ul>{/* ... */}</ul>;
}
// Nur die Datenfunktion gecacht
export async function getProducts() {
"use cache";
return db.query.products.findMany();
}
Zur Funktionsebene greife ich am häufigsten: den teuren Datenzugriff cachen, die Komponenten drumherum aber dynamisch bleiben lassen.
cacheLife — wie lang ist "eine Weile"
cacheLife legt das Revalidierungs-Profil für einen gecachten Scope fest. Es gibt benannte Profile, und du kannst eigene definieren:
import { unstable_cacheLife as cacheLife } from "next/cache";
export async function getBlogPosts() {
"use cache";
cacheLife("days"); // Blog-Inhalte, die täglich aktualisiert werden
return db.query.posts.findMany();
}
Wähle das Profil, das dazu passt, wie veraltet die Daten sein dürfen. Marketing-Texte? days. Ein Produktkatalog? hours. Greif nicht zur On-Demand-Invalidierung, solange ein Zeitfenster die Anforderung noch sinnvoll abbilden kann.
cacheTag + Invalidierung — und der Unterschied zwischen revalidate und update
Versieh einen gecachten Eintrag mit einem Tag, damit du ihn präzise invalidieren kannst, wenn sich die zugrunde liegenden Daten ändern:
import { unstable_cacheTag as cacheTag } from "next/cache";
export async function getProduct(id: string) {
"use cache";
cacheTag(`product-${id}`);
return db.query.products.findFirst({ where: eq(products.id, id) });
}
Wenn sich dann etwas ändert, hast du zwei Invalidierungs-Funktionen, und sie sind nicht austauschbar:
revalidateTag(tag)— nutzbar in Route Handlers, Webhooks oder überall sonst. Es markiert das Tag als stale; der nächste Request frischt es auf.updateTag(tag)— nur in Server Actions. Es ist für Read-your-own-writes gebaut: Es lässt das Tag sofort ablaufen, und das nächste Rendering wartet auf frische Daten, sodass der User, der gerade etwas angelegt hat, seine Änderung sieht und keine veraltete Kopie.
Die Regel, nach der ich vorgehe: Ein Webhook vom CMS ruft revalidateTag auf; ein User, der seine eigenen Daten bearbeitet, ruft in einer Server Action updateTag auf.
"use server";
import { updateTag } from "next/cache";
export async function updateProduct(id: string, data: FormData) {
await db.update(/* ... */);
updateTag(`product-${id}`); // dieser User sieht die Änderung sofort
}
Die Falle, die dich erwischen wird: cookies und headers
Ein gecachter Scope kann keine Request-Zeit-Daten wie cookies() oder headers() lesen — das würde "gecacht" bedeutungslos machen. Das vorgesehene Pattern ist, sie außerhalb des gecachten Scopes zu lesen und die Werte als Argumente hineinzureichen:
// page.tsx (dynamisch)
import { cookies } from "next/headers";
import { getDashboard } from "./data";
export default async function Page() {
const region = (await cookies()).get("region")?.value ?? "eu";
return <Dashboard promise={getDashboard(region)} />;
}
// data.ts
export async function getDashboard(region: string) {
"use cache";
cacheTag(`dashboard-${region}`);
// region kam als Argument herein — pro Region cachebar
}
Wenn du wirklich nicht so refactoren kannst, dass du die Werte hineinreichst, gibt es use cache: private für Caching pro User und use cache: remote, wenn der In-Memory-Cache nicht ausreicht und deine Plattform einen dedizierten Handler bereitstellt. Zu denen greifst du zuletzt.
Wie ich eine Seite tatsächlich strukturiere
Das Pattern, das zu meinem Standard für eine Content-plus-Personalisierung-Seite geworden ist:
- Statische Shell: Header, Hero, Marketing-Sections — gecacht, prerendert, sofort von der Edge ausgeliefert.
- Dynamische Löcher in
<Suspense>: das "Recently viewed", die Warenkorb-Anzahl, alles pro User — hineingestreamt.
export default async function Page() {
return (
<>
<MarketingSections /> {/* "use cache" — in der Shell */}
<Suspense fallback={<CartSkeleton />}>
<CartSummary /> {/* dynamisch — streamt hinein */}
</Suspense>
</>
);
}
Der Besucher sieht sofort eine vollständig wirkende Seite, und die personalisierten Teile füllen sich einen Moment später auf. Das ist das ganze Versprechen von PPR, und jetzt ist es der Standard statt eines Flags, das du rechtfertigen musst.
Migration aus der alten Welt
Wenn du von Next 15 oder früher kommst:
export const revalidate = 3600auf einem Segment → verlagere die Absicht in einenuse cache-Scope mitcacheLife.unstable_cache(fn, keys, { tags })→ eine Funktion mituse cache+cacheTag.experimental.ppr/experimental_pprRoute-Config → weg;cacheComponentsgibt dir PPR standardmäßig.
Der Guide "Migrating to Cache Components" in den Next-Docs führt durch die mechanischen Teile. Der konzeptionelle Wechsel ist der Teil, den es sich zu verinnerlichen lohnt: Du hast aufgehört zu deklarieren, was dynamisch ist, und angefangen zu deklarieren, was gecacht wird.
Wann ich es sein lasse
Cache Components glänzt, wenn eine Route stabile und pro-User-Inhalte mischt. Für eine vollständig statische Marketing-Seite ist es Overkill — schlichtes statisches Rendering ist einfacher. Für ein vollständig dynamisches Dashboard, bei dem nichts teilbar ist, lässt du ohnehin meistens alles dynamisch. Der Sweet Spot ist die unübersichtliche Mitte — und genau dort leben die meisten echten Kunden-Sites.