Caching in Next.js 15: Revalidierungs-Muster gegen veraltete und überflüssig geladene Daten

#Next.js 15 Caching Revalidierung
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Next.js 15 Caching-Revalidierung hat die Regeln auf eine Weise verändert, die Teams noch Monate nach der Migration überrascht. Die sichtbarste Verschiebung: fetch-Aufrufe cachen nicht mehr standardmäßig. In Next.js 13 und 14 wurde jedes fetch innerhalb einer Server Component unbegrenzt gecacht, sofern man nicht explizit ausgestiegen ist. In Next.js 15 gilt das Gegenteil: Jedes fetch ist standardmäßig ungecacht, und Caching aktivierst du bewusst.

Diese Änderung hat eine große Klasse versehentlicher Over-Caching-Bugs beseitigt. Sie hat aber auch eine neue eingeführt: Teams, die sich auf die alten Defaults verlassen und sich wundern, warum ihre Next.js-15-Apps bei jedem Request auf APIs eindreschen. Was sich geändert hat und welche Revalidierungs-Muster das alte implizite Verhalten ersetzen, ist Thema dieses Beitrags.

Was sich beim Caching in Next.js 15 geändert hat

Bevor es um Muster geht, hilft Präzision darüber, welche vier Caching-Schichten Next.js verwaltet und wie 15 jede einzelne angepasst hat.

Request Memoization ist in 15 weiterhin aktiv. Identische fetch-Aufrufe mit identischer URL und identischen Optionen innerhalb eines einzelnen Render-Baums werden automatisch dedupliziert. Das ist eine In-Memory-Optimierung pro Request, kein persistenter Cache. Sie verschwindet, sobald der Request abgeschlossen ist.

Data Cache ist der Ort des Breaking Change. In 14 legte fetch Responses standardmäßig im persistenten Data Cache ab (cache: 'force-cache'). In 15 ist der Default cache: 'no-store', also kein persistentes Caching, sofern du es nicht explizit konfigurierst. Wenn du eine 14er-App auf 15 migriert hast und plötzlich einen Anstieg an Datenbank- oder API-Aufrufen gesehen hast, liegt es mit hoher Wahrscheinlichkeit daran.

Full Route Cache (der serverseitige Cache für HTML- und RSC-Payload) wechselt nur noch für Routen in statisches Rendering, die überhaupt keine dynamischen Funktionen enthalten. Das Verhalten ähnelt 14, ist aber strenger darin, was als dynamisch zählt.

Router Cache (der clientseitige Cache bereits besuchter Routen) wurde ebenfalls angepasst: Die Standard-staleTime fiel in 15.0 für Page-Segmente auf null. Zurück auf eine zuvor gecachte Seite zu navigieren löst also standardmäßig einen frischen Server-Fetch aus. Du kannst das pro Layout oder pro Page überschreiben.

Die praktische Erkenntnis: In Next.js 15 wird nichts gecacht, worum du nicht bittest. Das ist ein sichererer Ausgangspunkt für Korrektheit, verlangt aber, dass du für jedes Stück Daten bewusst entscheidest, ob und wie lange es gecacht werden soll.

Muster 1: Zeitbasierte Revalidierung mit next.revalidate

Zeitbasierte Revalidierung ist das einfachste Muster und der richtige Default für Daten, die sich nach einem vorhersehbaren Rhythmus ändern: CMS-Inhalte, Preistabellen, Feature-Flag-Konfigurationen, Marketing-Texte.

// Revalidate at most every 3600 seconds (1 hour)
const res = await fetch('https://api.example.com/pricing', {
  next: { revalidate: 3600 },
})

Das entspricht dem alten revalidate-Export auf einer Page, ist aber granularer: Du kannst innerhalb derselben Route unterschiedliche TTLs für unterschiedliche Fetches setzen. Das Framework cacht die Response im Data Cache und liefert sie aus, bis die TTL abläuft. Beim nächsten Request nach Ablauf revalidiert Next.js im Hintergrund (Stale-while-Revalidate-Semantik) und aktualisiert den Cache mit der frischen Response.

Ein paar Dinge, die du bei diesem Muster im Kopf behalten solltest:

Die TTL ist eine Obergrenze, keine Garantie. Wenn der Server neu startet oder der Data Cache aus irgendeinem Grund geleert wird, löst der nächste Request unabhängig von der TTL einen frischen Fetch aus.

Wenn du revalidate: 0 setzt, ist das semantisch identisch mit cache: 'no-store', also gar kein Caching. Das ist nützlich, wenn du "immer frisch" explizit ausdrücken willst, statt dich auf den Default zu verlassen.

Für Routen, in denen jedes fetch dieselbe TTL hat, kannst du revalidate aus der Route-Segment-Konfiguration exportieren, statt es pro Fetch zu konfigurieren:

// app/pricing/page.tsx
export const revalidate = 3600

export default async function PricingPage() {
  const pricing = await fetch('https://api.example.com/pricing').then(r => r.json())
  // ...
}

Muster 2: On-Demand-Revalidierung mit revalidatePath und revalidateTag

Zeitbasierte Revalidierung passt schlecht zu Daten, die sich durch Nutzeraktionen oder externe Ereignisse ändern: ein Produktkatalog, den ein Händler aktualisiert, ein Blogbeitrag, den eine Redakteurin veröffentlicht, ein Bestellstatus, den ein Webhook ändert. Für diese Fälle ist On-Demand-Revalidierung das richtige Werkzeug.

revalidatePath invalidiert den Full Route Cache für einen bestimmten Pfad. Sobald der nächste Request diesen Pfad trifft, rendert Next.js die Seite neu und aktualisiert den Cache.

// app/actions/publishPost.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function publishPost(postId: string) {
  await db.posts.update({ id: postId, status: 'published' })
  revalidatePath('/blog')
  revalidatePath(`/blog/${postId}`)
}

revalidatePath nimmt ein optionales zweites Argument entgegen: 'page' (Default) oder 'layout'. 'layout' invalidiert alle Seiten, die dieses Layout teilen. Das ist nützlich, wenn ein Layout Daten rendert, die eine Mutation gerade geändert hat, etwa ein Navigationselement mit Zählerständen.

revalidateTag ist präziser und skaliert in großen Apps besser. Du taggst einzelne fetch-Aufrufe mit einem oder mehreren String-Identifiern und invalidierst dann nach Tag. Das entkoppelt Cache-Invalidierung von der URL-Struktur.

// Tagging a fetch
const product = await fetch(`https://api.example.com/products/${id}`, {
  next: { tags: ['product', `product-${id}`] },
})

// Invalidating by tag from a Server Action or Route Handler
import { revalidateTag } from 'next/cache'

export async function updateProduct(id: string, data: ProductUpdate) {
  await db.products.update({ id, ...data })
  revalidateTag(`product-${id}`)       // invalidates this specific product
  // revalidateTag('product')          // would invalidate all products
}

Tag-basierte Invalidierung ist besonders nützlich für Multi-Tenant- oder inhaltslastige Anwendungen, in denen dieselben Daten (eine geteilte Komponente, eine globale Konfiguration) in Hunderten von Routen eingebettet sind. Nach Tag zu invalidieren ist ein einziger Aufruf, egal wie viele Routen diese Daten einbetten.

Eine Einschränkung: revalidateTag wirkt nur auf den Data Cache (Fetch-Responses). Es greift nicht in clientseitige Caches. Wenn dein Client einen veralteten Router-Cache-Eintrag für eine Seite hat, die du gerade revalidiert hast, sieht der Nutzer die frischen Daten möglicherweise erst, wenn er wegnavigiert und zurückkommt oder der Router-Cache-Eintrag abläuft.

Muster 3: Webhook-getriebene Revalidierung über Route Handler

CMS-Plattformen, E-Commerce-Backends und Zahlungsdienstleister senden alle Webhooks, wenn sich Inhalt oder Zustand ändert. Diese Webhooks direkt an revalidateTag oder revalidatePath anzubinden ist sauberer als periodisches Polling und liefert nahezu Echtzeit-Cache-Updates, ohne dass du jede Route dynamisch machen musst.

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get('secret')
  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 })
  }

  const body = await request.json()
  const { type, id } = body

  switch (type) {
    case 'product.updated':
      revalidateTag(`product-${id}`)
      break
    case 'catalog.published':
      revalidateTag('catalog')
      break
    default:
      return NextResponse.json({ message: 'Unhandled event type' }, { status: 400 })
  }

  return NextResponse.json({ revalidated: true, at: Date.now() })
}

Zwei Dinge macht dieser Handler richtig, die viele Produktionsimplementierungen übersehen: die Prüfung des geteilten Secrets (validiere immer, dass der Webhook von dem kommt, für den er sich ausgibt) und ein aussagekräftiger Response-Body, den dein Webhook-Anbieter für Debugging protokollieren kann.

Bei Shopify, Contentful, Sanity, Stripe und den meisten anderen großen Plattformen konfigurierst du Webhook-Endpoint-URL und geteiltes Secret in deren Admin-Oberflächen und richtest sie auf diese Route. Ändert sich ein Produkt in Shopify, revalidieren deine Next.js-Produktseiten innerhalb von Sekunden, statt auf einen Cronjob oder eine ablaufende TTL zu warten.

Muster 4: Route-Level-Cache-Kontrolle für gemischt statische und dynamische Routen

Nicht jede Route einer Anwendung fällt sauber in "immer statisch" oder "immer dynamisch". Eine Produktdetailseite hat vielleicht einen statischen Header und eine Beschreibung (ändern sich selten, sicher cachebar) neben Echtzeit-Lagerbeständen und personalisierten Empfehlungen (müssen pro Request frisch sein).

Das richtige Werkzeug ist hier Partial Prerendering (PPR), kombiniert mit explizitem cache: 'no-store' auf den dynamischen Fetches:

// app/products/[id]/page.tsx
export const experimental_ppr = true

async function ProductHeader({ id }: { id: string }) {
  // Cached -- product description changes rarely
  const product = await fetch(`https://api.example.com/products/${id}`, {
    next: { revalidate: 3600, tags: [`product-${id}`] },
  }).then(r => r.json())

  return <ProductDetails product={product} />
}

async function StockLevel({ id }: { id: string }) {
  // Always fresh -- stock changes constantly
  const stock = await fetch(`https://api.example.com/products/${id}/stock`, {
    cache: 'no-store',
  }).then(r => r.json())

  return <StockBadge level={stock.available} />
}

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      <ProductHeader id={params.id} />
      <Suspense fallback={<StockSkeleton />}>
        <StockLevel id={params.id} />
      </Suspense>
    </div>
  )
}

Mit aktiviertem PPR rendert ProductHeader in die statische Shell, die am Edge gecacht wird. StockLevel streamt zur Request-Zeit mit einem frischen Fetch nach. Der Nutzer bekommt sofortige wahrgenommene Performance aus der statischen Shell und korrekte Daten aus den dynamischen Abschnitten, ohne zwei getrennte Routen oder einen clientseitigen Fetch-Wasserfall.

Muster 5: Unstable Cache für Datenquellen ohne fetch

Fetch-Caching gilt nur für tatsächliche fetch-Aufrufe. Wenn deine Daten aus einer Datenbankabfrage, einem ORM, einem Dateisystem-Read oder einem Drittanbieter-SDK stammen, das intern kein fetch nutzt, greift keines der obigen Muster direkt.

Next.js bietet unstable_cache (trotz des Namens in der Praxis stabil), um Caching auf beliebige asynchrone Funktionen auszuweiten:

import { unstable_cache } from 'next/cache'

const getCachedProduct = unstable_cache(
  async (id: string) => {
    return await db.products.findUnique({ where: { id } })
  },
  ['product'],  // cache key prefix
  {
    revalidate: 3600,
    tags: [`product`],
  }
)

// In your Server Component
const product = await getCachedProduct(params.id)

Der Cache-Key leitet sich aus dem Präfix-Array plus den Funktionsargumenten ab, getCachedProduct('abc123') und getCachedProduct('def456') werden also getrennt gespeichert. Tags funktionieren genauso wie bei fetch: Du kannst revalidateTag('product') aus einer Server Action aufrufen, und das Ergebnis dieser gecachten Funktion wird invalidiert.

unstable_cache ist besonders nützlich für Teams, die eine Legacy-Codebasis auf den Next.js 15 App Router migrieren. Wenn du eine bestehende Datenzugriffsschicht rund um ein ORM oder eine eigene Query-Bibliothek hast, kannst du kritische Queries in unstable_cache wickeln und den App Router schrittweise einführen, ohne das gesamte Data Fetching auf fetch umzuschreiben.

Häufige Fehler und wie du sie vermeidest

Caching innerhalb dynamischer Route-Segmente ohne Berücksichtigung der Parameter. Wenn du unstable_cache nutzt, ohne die relevanten Parameter in den Cache-Key aufzunehmen, teilen sich alle Nutzer dasselbe gecachte Ergebnis. Eine Produktseite, die Lagerbestandsdaten ohne Produkt-ID im Key cacht, liefert für jedes Produkt außer dem ersten, das den Cache füllt, den falschen Bestand.

revalidatePath verwenden, wo revalidateTag passender wäre. revalidatePath invalidiert HTML- und RSC-Payload für eine bestimmte URL. Wenn dieselben Daten in einem geteilten Layout, einer Sidebar und drei verschiedenen Seiten auftauchen, brauchst du drei revalidatePath-Aufrufe. revalidateTag erledigt alle mit einem Aufruf, solange die Fetches konsistent getaggt wurden.

Sofortiges Cache-Leeren von revalidatePath / revalidateTag erwarten. Beide Funktionen markieren gecachte Einträge als veraltet. Das eigentliche Re-Rendering passiert beim nächsten Request auf diese Route, nicht im Moment des Revalidate-Aufrufs. Das ist so gewollt (Stale-while-Revalidate-Semantik), aber Teams nehmen manchmal an, der Cache sei bereits warm und frisch, wenn ein Redirect oder eine Navigation abgeschlossen ist. Meistens stimmt das auch, da die von der Server Action ausgelöste Revalidierung feuert, bevor die Action zurückkehrt. Unter hoher Nebenläufigkeit oder in Edge-Umgebungen mit mehreren Instanzen ist ein kurzes Fenster veralteter Daten aber möglich.

revalidate: 0 setzen im Sinne von "cache das, aber prüfe oft". Wie oben erwähnt bedeutet revalidate: 0 gar kein Cache, äquivalent zu cache: 'no-store'. Wenn du sehr häufige Revalidierung willst, setze eine kleine positive Zahl (revalidate: 30) statt null.

Das richtige Muster wählen

Der Entscheidungsbaum ist recht direkt, sobald du jedes Stück Daten charakterisiert hast:

Daten, die sich fast nie ändern (Konfiguration, Rechtstexte, Navigationsstruktur): revalidate mit langer TTL nutzen und taggen, damit du bei einer seltenen Änderung gezielt invalidieren kannst.

Daten, die sich nach bekanntem Rhythmus ändern (Tagesangebote, Wochenreports, nächtliche Syncs): revalidate mit einer TTL, die zur Aktualisierungsfrequenz passt.

Daten, die sich als Reaktion auf Nutzeraktionen oder externe Ereignisse ändern: revalidateTag oder revalidatePath aus Server Actions und Route Handlern. Für externe Ereignisse an einen Webhook-Endpoint anbinden.

Daten, die für jeden Nutzer immer frisch sein müssen (personalisierte Inhalte, Echtzeit-Feeds, auth-geschützte Responses): explizit cache: 'no-store' nutzen und diese Fetches in Suspense-Boundaries isolieren, wenn der Rest der Seite vom Caching profitieren soll.

Daten aus Nicht-Fetch-Quellen (ORMs, SDKs, Dateizugriffe): in unstable_cache wickeln, mit derselben TTL- und Tag-Strategie wie bei einem fetch-Aufruf.

Architektur zählt mehr als API-Wissen

Caching in Next.js 15 ist mächtig, aber es ist auch der Ort, an dem sich subtile Bugs am längsten verstecken: veraltete Daten, die den falschen Nutzern angezeigt werden, Cache-Invalidierungen, die einzelne Routen verfehlen, Tag-Diskrepanzen zwischen dem Ort des Datenabrufs und dem Ort der Mutation. Das sind keine Next.js-spezifischen Probleme, sondern die inhärente Komplexität von Caching auf jeder Ebene.

Die Architektur von Anfang an richtig zu setzen, also zu entscheiden, was in den Data Cache gehört, was dynamisch bleibt und wie Mutationen auf Revalidierungsaufrufe abgebildet werden, ist eine Designentscheidung, die sich über die gesamte Lebensdauer der Anwendung auszahlt. Wenn dein Team eine individuelle Webanwendung baut oder ein bestehendes Produkt technisch überarbeiten will und erfahrene Augen auf die Caching-Strategie sucht: Wolf-Tech arbeitet genau an solchen Entscheidungen direkt mit Engineering-Teams. Schreib an hello@wolf-tech.io mit einer kurzen Beschreibung dessen, was du baust.