Next.js 16 Partial Prerendering (PPR) in Produktion: Static Shell + Streaming-Architektur (2026)

Praxisleitfaden zu Partial Prerendering in Next.js 16: statische Shell plus dynamische Suspense-Holes, cacheComponents-Flag, use-cache-Direktive, updateTag-Invalidierung und Edge-Deployment auf Vercel mit echten Migrationsfallstricken.

Next.js 16 PPR Guide: Setup 2026

Aktualisiert: 17. Juni 2026

Partial Prerendering (PPR) in Next.js 16 ist eine Rendering-Strategie, bei der eine statische HTML-Shell zur Build-Zeit erzeugt und beim Request sofort ausgeliefert wird, während dynamische Bereiche innerhalb von <Suspense>-Grenzen parallel als HTTP-Stream nachgeliefert werden. Seit Next.js 16 (Oktober 2025) ist PPR über die cacheComponents: true-Flag stabil; das alte experimental.ppr sowie experimental.dynamicIO sind entfernt. In diesem Leitfaden zeige ich, wie ich PPR in einer SaaS-Codebasis produktiv ausgerollt habe — inklusive Turbopack-Build, Vercel-Edge-Caching und der Stolperfallen, die im Migrationspfad lauern.

  • PPR ist ab Next.js 16 stabil und wird über cacheComponents: true in next.config.ts aktiviert, kein Per-Route-Opt-in mehr.
  • Die statische Shell wird beim Build erzeugt, dynamische Holes innerhalb von <Suspense> streamen per HTTP-Chunk in derselben Response.
  • Cache Components vereinigt dynamicIO, PPR und unstable_cache in einem Modell mit "use cache", cacheLife() und cacheTag().
  • Invalidierung erfolgt auf Region-Ebene mit updateTag(), kein vollständiger Rebuild des Catalog-Trees nötig.
  • Ohne <Suspense>-Boundary um ungecachte Daten bricht der Build mit "Uncached data was accessed outside of <Suspense>" ab.
  • PPR lohnt sich für stabile Shells mit kleinen Personalisierungs-Holes (Produktseiten, Pricing, Dashboard-Header); für vollständig dynamische Routes bleibt klassisches SSR günstiger.

Was ist Partial Prerendering in Next.js 16?

Partial Prerendering kombiniert statisches und dynamisches Rendering innerhalb einer einzigen Route. Zur Build-Zeit erzeugt Next.js zwei Artefakte pro PPR-Route: eine statische HTML-Shell und einen sogenannten postponedState-Blob, der React mitteilt, wo die dynamischen Lücken anfangen. Beim Request liefert der Server die Shell sofort als ersten HTTP-Chunk aus, parst dann den postponedState und streamt die dynamischen Bereiche, alles in derselben Response, ohne zweiten Roundtrip.

In meiner Architektur-Praxis ist das die wichtigste Veränderung seit dem App Router selbst: das Page-Level-Rendering-Modell ("statisch ODER dynamisch") wird durch ein Component-Level-Modell ersetzt. Eine Produktseite kann den Hero-Banner, die SEO-Metadaten und das Produkt-Schema statisch ausliefern, während Lagerbestand, Preis und Empfehlungs-Widget aus der Datenbank streamen. Google-Crawler sehen die statische Shell innerhalb von Millisekunden, was Largest Contentful Paint deutlich verbessert und damit auch das Indexierungs-Ranking.

Die Voraussetzung dafür: React 19 mit der überarbeiteten Suspense-Semantik. Jede Stelle, die nicht zur Build-Zeit aufgelöst werden kann, Cookies lesen, headers() aufrufen, eine ungecachte Datenbankabfrage, muss in eine <Suspense>-Boundary verpackt sein. Das ist nicht optional: Der Build bricht ab, sobald Next.js eine ungecachte I/O-Operation außerhalb einer Boundary findet.

Wie aktiviere ich PPR mit cacheComponents?

In Next.js 16 wurde die alte experimental.ppr-Flag entfernt und durch eine einzige, stabile Konfigurationsoption ersetzt. Das ist eine bewusste Vereinfachung, PPR ist kein opt-in-Feature mehr, sondern das Standardverhalten, sobald Cache Components aktiv sind.

// next.config.ts, Next.js 16+
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // Aktiviert PPR + use-cache-Direktive + cacheLife/cacheTag/updateTag
  cacheComponents: true,

  // Turbopack ist in 16.x Standard für Dev UND Build.
  // Webpack-Opt-out nur für Legacy-Pipelines:
  // bundler: "webpack",

  experimental: {
    // React Compiler ergänzt PPR sinnvoll, ist aber unabhängig
    reactCompiler: true,
  },
};

export default nextConfig;

Nach dem Flippen der Flag ändert sich das mentale Modell der gesamten Codebasis. Daten sind jetzt dynamisch by default, was zur Build-Zeit nicht explizit gecacht ist, läuft beim Request. Das ist die Inverse zur alten App-Router-Welt, in der Pages standardmäßig statisch waren und dynamic = "force-dynamic" den Opt-out darstellte.

Wer aus einer Codebasis mit experimental.dynamicIO kommt: Die Migration ist mechanisch, Flag umbenennen, unstable_cache-Aufrufe durch die "use cache"-Direktive ersetzen. Die offiziellen cacheComponents-Dokumente von Next.js beschreiben den vollständigen Codemod. In unserem Monorepo lief der Codemod über 220 Komponenten in unter zwei Minuten; die manuelle Nacharbeit lag in den Bereichen, in denen wir unstable_cache mit dynamisch berechneten Keys benutzt hatten, die "use cache"-Direktive berechnet ihren Key automatisch aus der Closure, was bei dynamischen Keys eine andere Strukturierung verlangt.

Statische Shell vs. dynamische Holes: das mentale Modell

Die wichtigste Frage beim PPR-Design ist nicht "wie rendere ich diese Komponente?" sondern "wo ziehe ich die Suspense-Grenze?". Die Boundary ist der Trenner zwischen Build-Time und Request-Time. Alles oberhalb wird in die statische Shell kompiliert, alles innerhalb wird beim Request gerendert und gestreamt.

// app/produkt/[slug]/page.tsx
import { Suspense } from "react";
import { Hero } from "./hero";
import { CartButton } from "./cart-button";
import { Recommendations } from "./recommendations";
import { Skeleton } from "@/components/ui/skeleton";

export default async function ProductPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  return (
    <main>
      {/* Statische Shell, wird zur Build-Zeit erzeugt */}
      <Hero slug={slug} />

      {/* Dynamischer Hole, wird beim Request gestreamt */}
      <Suspense fallback={<Skeleton className="h-12 w-32" />}>
        <CartButton slug={slug} />
      </Suspense>

      {/* Zweiter Hole, parallel gestreamt */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations slug={slug} />
      </Suspense>
    </main>
  );
}

Wichtig: Die Suspense-Boundary muss vor dem await stehen, das die dynamische Operation auslöst. Wer eine async-Funktion innerhalb eines Server Components ohne umgebende Boundary aufruft, sieht im Build den Fehler "Uncached data was accessed outside of <Suspense>". Das ist die häufigste Build-Regression in der Migration, in unserem Codebase betraf sie 14 Komponenten, die cookies() oder headers() innerhalb von Layout-Komponenten aufriefen.

Aus Build-Systems-Sicht ist die Suspense-Boundary auch ein Caching-Grenzobjekt: Jede Boundary erzeugt einen separaten HTTP-Chunk im Streaming-Response. Mehr Boundaries bedeuten feinere Granularität, aber auch mehr TCP-Overhead. In der Praxis liegt die optimale Anzahl bei 2–4 Suspense-Boundaries pro Seite, alles darüber hinaus erzeugt Network-Waterfalls, die Edge-CDNs nicht effizient batchen können.

Die "use cache"-Direktive in der Praxis

Die "use cache"-Direktive ist das Pendant zu "use server" und "use client": Sie markiert eine Komponente, eine Funktion oder eine ganze Datei als cacheable. Next.js berechnet den Cache-Key automatisch aus dem Funktionscode, den Argumenten und den geschlossenen Variablen, ähnlich dem React-Compiler, aber für den Server.

// app/produkt/[slug]/product-data.ts
import { db } from "@/lib/db";
import { cacheLife, cacheTag } from "next/cache";

export async function getProduct(slug: string) {
  "use cache";

  // Diese Werte bestimmen die Cache-Lifetime und Tags.
  // Sie werden NACH der Direktive aber VOR dem Datenzugriff aufgerufen.
  cacheLife("hours");
  cacheTag(`product:${slug}`);

  const product = await db.products.findUnique({
    where: { slug },
    include: { variants: true, schema: true },
  });

  if (!product) {
    throw new Error(`Produkt ${slug} nicht gefunden`);
  }

  return product;
}

Die cacheLife()-Funktion akzeptiert vordefinierte Profile ("minutes", "hours", "days", "weeks", "max") oder ein Custom-Objekt mit stale, revalidate und expire. cacheTag() hängt einen Invalidations-Tag an, den Server Actions oder Webhook-Endpunkte später per updateTag() invalidieren können.

Ein wichtiges Detail: Die "use cache"-Direktive cacht auf dem Server, nicht im Browser. Das ist orthogonal zu HTTP-Caching-Headern. Im Vercel-Setup landet das Cache-Artefakt im durable storage (Vercel Data Cache); in Self-Hosting-Setups übernimmt der eingebaute LRU-Cache mit dem in next.config.ts konfigurierten Backend. Für eine vertiefte Behandlung dieses Modells siehe unseren Deep Dive zu Cache Components und der use-cache-Direktive.

Was ist der Unterschied zwischen PPR und ISR?

Das ist die Frage, die mir Staff-Engineers in Architektur-Reviews am häufigsten stellen. Die kurze Antwort: ISR cacht ganze Pages und revalidiert sie als Einheit; PPR cacht Komponenten-Subtrees und kombiniert sie mit Echtzeit-Streams in derselben Response.

DimensionISR (Incremental Static Regeneration)PPR (Partial Prerendering)
Cache-GranularitätGanze PagePro Suspense-Boundary
InvalidierungPer revalidatePath() oder revalidate-ExportPer updateTag() auf Region-Ebene
Dynamische DatenErfordert Client-Komponente nach HydrationServer-streamed im Initial-HTML
SEO für Echtzeit-InhalteNur stale-while-revalidate-SnapshotFrische Daten im First Paint sichtbar
Build-ZeitHoch (alle Pages prerendered)Mittel (nur statische Shells)
Edge-KompatibilitätVollständigVollständig, Shell + Stream beide edge-served
Typischer Use-CaseBlog, Dokumentation, MarketingE-Commerce-Produktseiten, SaaS-Dashboards

Vollständiges Beispiel: Produktseite mit PPR

Hier ist das End-to-End-Pattern, das wir auf einer SaaS-Produktseite mit 8.000 SKUs im Catalog produktiv einsetzen. Statische Shell mit Produkt-Metadaten (für SEO und LCP), zwei dynamische Holes für Preis-Personalisierung und Live-Lagerbestand.

// app/produkt/[slug]/page.tsx
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { getProduct } from "./product-data";
import { LivePrice } from "./live-price";
import { StockStatus } from "./stock-status";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const product = await getProduct(slug);
  return {
    title: `${product.name} | Acme Shop`,
    description: product.shortDescription,
  };
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const product = await getProduct(slug);

  if (!product) notFound();

  return (
    <article>
      <h1>{product.name}</h1>
      <img src={product.imageUrl} alt={product.name} />
      <p>{product.description}</p>

      <Suspense fallback={<span className="price-skeleton" />}>
        <LivePrice sku={product.sku} />
      </Suspense>

      <Suspense fallback={<span>Lager wird geprüft…</span>}>
        <StockStatus sku={product.sku} />
      </Suspense>
    </article>
  );
}
// app/produkt/[slug]/live-price.tsx
import { cookies } from "next/headers";
import { fetchPrice } from "@/lib/pricing-engine";

export async function LivePrice({ sku }: { sku: string }) {
  // Dynamische Daten, KEIN "use cache" hier
  const cookieStore = await cookies();
  const region = cookieStore.get("region")?.value ?? "DE";

  const price = await fetchPrice({ sku, region });

  return (
    <p className="price">
      {new Intl.NumberFormat("de-DE", {
        style: "currency",
        currency: "EUR",
      }).format(price)}
    </p>
  );
}

Das ergibt im Network-Tab folgendes Bild: Der erste HTTP-Chunk enthält die Shell mit Produktname, Bild, Beschreibung und beiden Skeleton-Fallbacks, sichtbar in ungefähr 200–400 ms ab Edge-PoP. Dann streamen die zwei Suspense-Holes parallel; LivePrice löst in ca. 180 ms ein, StockStatus in 320 ms. Crawler sehen die Shell und können die Page indexieren, ohne auf die dynamischen Bereiche zu warten.

Cache-Invalidierung mit updateTag

Sobald die Catalog-Daten sich ändern, neue Beschreibung, neues Hero-Bild, geänderte Produkt-Kategorisierung, muss der statische Cache invalidiert werden. In PPR-Architekturen passiert das nicht mehr auf Page-Ebene, sondern auf Tag-Ebene. Eine Server Action oder ein Webhook-Endpoint ruft updateTag() auf, und alle Komponenten mit dem entsprechenden cacheTag() werden beim nächsten Request neu gerendert.

// app/actions/refresh-product.ts
"use server";

import { updateTag } from "next/cache";
import { z } from "zod";

const schema = z.object({ slug: z.string().min(1) });

export async function refreshProduct(formData: FormData) {
  const { slug } = schema.parse({ slug: formData.get("slug") });

  // Invalidiert nur die Komponenten, die diesen Tag verwenden.
  // Andere Produkt-Caches bleiben intakt.
  updateTag(`product:${slug}`);

  return { ok: true };
}

Die Granularität ist entscheidend: Bei 8.000 SKUs bedeutet eine Page-Level-Invalidierung 8.000 Builds; eine Tag-basierte Invalidierung trifft nur die ein bis zwei Komponenten, die diesen Tag konsumieren. Die offiziellen PPR Platform Guide Docs von Vercel beschreiben das Vergleichsverhalten für andere Hosting-Plattformen.

Für absicht­liche Mutationen aus dem Frontend, Add-to-Cart, Wishlist-Toggle, Profil-Updates, verwende ich das Pattern, das ich in unserem Artikel zu Next.js Server Actions, Formularen, Mutationen und Sicherheit beschrieben habe: Action schreibt in die DB, ruft updateTag(), und useOptimistic() übernimmt das Rendering bis die nächste Response da ist.

Migrationsfallstricke und Build-Fehler

Aus der Migration eines mittelgroßen SaaS-Repos (95 Routes, 220 Komponenten) sind das die fünf häufigsten Failure-Modes:

1. "Uncached data was accessed outside of <Suspense>"

Tritt auf, wenn eine Komponente cookies(), headers(), draftMode() oder eine ungecachte Datenquelle aufruft, ohne in eine Suspense-Boundary verpackt zu sein. Fix: Boundary um den dynamischen Bereich legen oder die Datenquelle mit "use cache" markieren. Den Stack-Trace sorgfältig lesen, er zeigt exakt den File-Pfad.

2. Layout-Pollution durch ungecachte Top-Level-Daten

Wenn ein layout.tsx cookies() für Auth-State liest, "infiziert" das alle Pages unter dem Layout-Subtree. Lösung: Den Auth-Read in eine eigene Suspense-Boundary innerhalb des Layouts isolieren oder per Middleware/proxy.ts vorverarbeiten. Details zu sicheren Middleware-Patterns in unserem Artikel zu Middleware-Sicherheit und proxy.ts-Migration.

3. Dynamische Cache-Keys werden zu Cache-Misses

Anders als bei unstable_cache kann der Key der "use cache"-Direktive nicht manuell gesetzt werden, er wird aus der Closure abgeleitet. Wer eine Funktion mit new Date() oder Math.random() in der Closure cacht, bekommt entweder einen permanenten Cache-Miss oder einen unerwartet eingefrorenen Wert.

4. Streaming-Buffering in Reverse Proxies

Wenn die Production hinter NGINX oder einem nicht-Vercel-CDN liegt, müssen Transfer-Encoding: chunked und HTTP/1.1 keep-alive aktiv sein. NGINX standardmäßig puffert Streaming-Responses; proxy_buffering off; in der Location-Konfiguration ist Pflicht, sonst sieht der User die Shell und alle Holes gleichzeitig (nicht progressiv).

5. Build-Zeit-Explosion bei tiefen Catalog-Trees

Mit generateStaticParams für 8.000 Produkte kann der Build-Step die statische Shell für jeden einzelnen Slug erzeugen. Das ist meistens unerwünscht, die Shell-Erzeugung beim ersten Request (ISR-style) ist günstiger. Lösung: generateStaticParams für die Top-200-Produkte begrenzen und den Rest on-demand erzeugen lassen.

Monitoring, Edge-Runtime und Vercel-Deployment

PPR in Production ist nur so gut wie die Observability dahinter. Drei Metriken sind kritisch: Time to First Byte (TTFB) der Shell, Time to First Stream Chunk (TTFSC) für die ersten dynamischen Daten, und Stream Completion Time für den letzten Chunk. Klassische Real-User-Monitoring-Tools wie Vercel Speed Insights instrumentieren TTFB nativ; für die Stream-Metriken haben wir einen eigenen PerformanceObserver hinzugefügt, der die navigation-Entry mit den resource-Entries der HTML-Response korreliert.

Die Next.js Release Notes dokumentieren, dass seit 16.2 alle use cache-, cacheLife-, cacheTag- und updateTag-APIs ohne unstable_-Präfix stabil sind. Das ist relevant, wenn die Codebase noch alte Imports referenziert, der Compiler warnt, baut aber weiter; in CI sollte ein Lint-Regel-Block auf unstable_cache stehen.

Auf der Edge: Vercel führt PPR-Routes auf der Edge-Runtime aus, sofern die Komponente nicht Node-spezifische APIs nutzt. Datenbank-Aufrufe gehen typischerweise über HTTP-Proxies (Vercel Postgres, Neon-Serverless, PlanetScale). Wer mit Drizzle ORM und PostgreSQL arbeitet, siehe unseren Leitfaden zu Drizzle ORM und PostgreSQL im App Router, muss die Driver-Konfiguration explizit auf den HTTP-Variant umstellen, sonst schlägt der Edge-Build mit "Module not found: Can't resolve 'net'" fehl.

Häufig gestellte Fragen

Kann ich PPR mit der Edge Runtime nutzen?

Ja. PPR und Edge Runtime sind kompatibel, Vercel führt sowohl die statische Shell als auch die dynamischen Suspense-Streams auf der Edge aus. Voraussetzung: keine Node-spezifischen APIs in den Server Components, und Datenbankzugriffe über HTTP-fähige Driver (Neon HTTP, Vercel Postgres, PlanetScale).

Was passiert, wenn ein dynamischer Hole fehlschlägt?

Die Suspense-Boundary fällt auf ihre Error Boundary zurück (oder den nächsten umgebenden error.tsx). Die statische Shell bleibt intakt und wird vollständig gerendert. Architektonisch ist das stabiler als klassisches SSR, wo ein einzelner Upstream-Fehler eine 500-Response für die gesamte Page erzeugt.

Ist Partial Prerendering in Next.js 16 stabil?

Ja, seit dem Next.js 16 Release im Oktober 2025. Die alte experimental.ppr-Flag wurde entfernt und durch das stabile cacheComponents: true ersetzt. Seit 16.2 sind auch use cache, cacheLife, cacheTag und updateTag ohne unstable_-Präfix verfügbar.

Wie viele Suspense-Boundaries sollte eine PPR-Page maximal haben?

In unserer Produktions-Erfahrung sind 2–4 Boundaries optimal. Mehr Boundaries erzeugen mehr HTTP-Chunks und können auf langsamen Netzwerken zu Waterfall-Effekten führen. Wenn eine Page mehr als fünf wirklich unabhängige dynamische Bereiche hat, ist das oft ein Signal, dass die Page in mehrere Routes geteilt werden sollte.

Muss ich von ISR zu PPR migrieren?

Nein. Für rein statische Inhalte (Blog, Docs, Marketing-Landing-Pages) bleibt klassisches Static Generation mit revalidate-Export die einfachste Lösung. PPR ist die richtige Wahl, sobald eine Page sowohl statische als auch personalisierte/dynamische Bereiche kombiniert, typischerweise Produktseiten, Pricing, SaaS-Dashboards mit User-State.

Mei-Lin Wu
Über den Autor Mei-Lin Wu

Front-end architect at a SaaS. Owns the build system, the design system, and the war stories about both.