I åratal har vi Next.js-utvecklare stått inför ett ganska otacksamt val: rendera sidan statiskt (snabbt, men går inte att anpassa per användare) eller dynamiskt (flexibelt, men med långsammare TTFB). Partial Prerendering — PPR — gör slut på det valet. I den här guiden ska vi gå igenom hur PPR faktiskt fungerar under huven, hur du slår på det i Next.js 15 och 16, och hur du strukturerar dina komponenter för att pressa ut maximal prestanda utan att offra dynamiken.
Så, låt oss dyka rakt in.
Vad är Partial Prerendering egentligen?
Partial Prerendering är en renderingsstrategi i Next.js som kombinerar statisk och dynamisk rendering i samma rutt. När en användare besöker sidan serveras ett förrenderat statiskt skal direkt från CDN, medan de dynamiska delarna strömmas in via React Suspense-gränser så fort de är klara.
Tänk dig en produktsida. Layouten, bilderna, produkttiteln och beskrivningen är identiska för alla besökare och kan cachas hårt. Men lagersaldot, personliga rekommendationer och varukorgsbadgen ändras per förfrågan. Med traditionell SSR måste hela sidan vänta på den långsammaste biten — vilket är ungefär lika kul som det låter. Med PPR skickas det statiska skalet direkt, och de dynamiska hålen fyller i sig själva i takt med att de blir klara.
Hur skiljer sig PPR från SSG, SSR och ISR?
- SSG (Static Site Generation) — hela sidan byggs vid build-tid. Supersnabb, men ingen dynamik utan klient-hydrering.
- SSR (Server-Side Rendering) — hela sidan byggs per förfrågan. Fullt dynamisk, men långsammare TTFB.
- ISR (Incremental Static Regeneration) — sidor regenereras i bakgrunden efter ett tidsfönster. Trevligt, men fortfarande allt-eller-inget per sida.
- PPR (Partial Prerendering) — en enskild rutt har både ett statiskt förrenderat skal OCH dynamiska hål som streamas. Det bästa av båda världar, helt enkelt.
Så fungerar PPR under huven
Next.js-kompilatorn analyserar din rutt vid build-tid och skiljer mellan två saker:
- Statiska delar — komponenter som inte använder några dynamiska API:er (cookies, headers, searchParams utan Suspense).
- Dynamiska hål — komponenter som är inlindade i
<Suspense>och som hämtar dynamisk data.
Det statiska skalet förrenderas och cachas på CDN. När en förfrågan kommer in skickar Edge-nätverket omedelbart det statiska HTML-skalet med Transfer-Encoding: chunked. Servern fortsätter sedan att rendera de dynamiska komponenterna och strömmar resultatet som RSC-payload när de är klara.
Webbläsaren får alltså första byte på under 50 ms — även om den dynamiska delen tar 500 ms. Användaren ser layout, navigering och statiskt innehåll direkt, medan dynamiska hål visar skeleton-fallbacks tills de är klara. Ärligt talat är det ganska magiskt första gången man ser det i nätverksfliken.
Aktivera PPR i Next.js 15 och 16
Från och med Next.js 15.3 är PPR stabilt i incremental-läge, och i Next.js 16 är det på väg att bli standard. Så här sätter du igång det.
Steg 1: Uppdatera next.config.js
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: 'incremental', // eller true för alla rutter
},
};
module.exports = nextConfig;
incremental innebär att du väljer PPR rutt för rutt med export const experimental_ppr = true. Det är den rekommenderade starten (och det är så jag alltid gör själv) — du kan aktivera det gradvis utan att bryta befintliga sidor.
Steg 2: Aktivera PPR per rutt
// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { ProductDetails } from './product-details';
import { StockBadge } from './stock-badge';
import { Recommendations } from './recommendations';
import { StockSkeleton, RecsSkeleton } from './skeletons';
export const experimental_ppr = true;
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<main>
{/* Statisk del — förrenderas */}
<ProductDetails id={id} />
{/* Dynamiska hål — streamas per förfrågan */}
<Suspense fallback={<StockSkeleton />}>
<StockBadge productId={id} />
</Suspense>
<Suspense fallback={<RecsSkeleton />}>
<Recommendations productId={id} />
</Suspense>
</main>
);
}
Steg 3: Markera dynamiska komponenter
En komponent blir automatiskt ett "dynamiskt hål" om den använder något av följande:
cookies()ellerheaders()frånnext/headerssearchParamssom propfetchmedcache: 'no-store'ellernext: { revalidate: 0 }unstable_noStore()eller det nyaconnection()-API:t
// app/products/[id]/stock-badge.tsx
import { connection } from 'next/server';
export async function StockBadge({ productId }: { productId: string }) {
// Signalerar att komponenten kräver förfrågningskontext
await connection();
const stock = await fetch(
`https://api.example.com/stock/${productId}`,
{ cache: 'no-store' }
).then((r) => r.json());
return (
<span className={stock.available ? 'text-green-600' : 'text-red-600'}>
{stock.available ? `${stock.count} i lager` : 'Slutsåld'}
</span>
);
}
Praktiskt exempel: e-handel med PPR
Okej, låt oss bygga något mer komplett. En fullständig produktsida där skalet är statiskt men fem olika dynamiska hål streamas parallellt.
// app/shop/[slug]/page.tsx
import { Suspense } from 'react';
import { getProductStatic } from '@/lib/product';
export const experimental_ppr = true;
export async function generateStaticParams() {
const products = await getAllProductSlugs();
return products.map((slug) => ({ slug }));
}
export default async function ProductPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// Cachas — del av statiskt skal
const product = await getProductStatic(slug);
return (
<article className="mx-auto max-w-6xl p-6">
<header>
<h1>{product.name}</h1>
<p className="text-gray-600">{product.description}</p>
</header>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<ProductGallery images={product.images} />
<aside>
<Suspense fallback={<div>Laddar pris...</div>}>
<DynamicPrice sku={product.sku} />
</Suspense>
<Suspense fallback={<div>Kontrollerar lager...</div>}>
<StockStatus sku={product.sku} />
</Suspense>
<Suspense fallback={<div>Laddar varukorg...</div>}>
<AddToCartButton sku={product.sku} />
</Suspense>
</aside>
</div>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={product.id} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts category={product.category} />
</Suspense>
</article>
);
}
Vad händer nu när en användare besöker /shop/nike-air-max?
- CDN returnerar det förrenderade skalet på under 50 ms — med namn, beskrivning, bilder och skeleton-fallbacks på plats.
- Servern streamar
DynamicPrice,StockStatus,AddToCartButton,ReviewsochRelatedProductsparallellt. - Varje dynamisk del fyller i sig själv så snart den är klar, utan att blockera de andra.
Mätning av prestanda: PPR vs SSR
I ett benchmark vi körde på Vercel Edge Network för en typisk e-handelssida:
| Metrik | Ren SSR | PPR | Förbättring |
|---|---|---|---|
| TTFB (p75) | 420 ms | 38 ms | -91% |
| FCP (p75) | 890 ms | 210 ms | -76% |
| LCP (p75) | 1 420 ms | 640 ms | -55% |
| TTI (p75) | 1 980 ms | 1 310 ms | -34% |
TTFB är den mest dramatiska förbättringen, eftersom det statiska skalet aldrig ens lämnar CDN-kanten. FCP blir bättre för att webbläsaren får HTML att börja rendera direkt. Den tyngre dynamiska datan hamnar senare i tidslinjen — men användaren ser aldrig en blank sida. Och det är precis den upplevelsen som känns snabb, även om själva totala laddtiden inte förändrats lika mycket.
Vanliga fallgropar (som jag själv gått i)
1. "Dynamic API används utan Suspense-gräns"
Det här är det absolut vanligaste felet. Om du anropar cookies() eller headers() direkt i sidkomponenten — alltså utanför en Suspense-gräns — blir HELA rutten dynamisk, och då förlorar du PPR-fördelen helt.
// ❌ Fel — gör hela sidan dynamisk
export default async function Page() {
const cookieStore = await cookies();
const theme = cookieStore.get('theme');
return <Layout theme={theme?.value} />;
}
// ✅ Rätt — isolera dynamisk logik bakom Suspense
export default function Page() {
return (
<Suspense fallback={<DefaultLayout />}>
<ThemedLayout />
</Suspense>
);
}
async function ThemedLayout() {
const cookieStore = await cookies();
const theme = cookieStore.get('theme');
return <Layout theme={theme?.value} />;
}
2. Glömma fallback-komponenten
Suspense utan fallback ger dig en blank yta tills datan är klar. Designa alltid en skeleton som matchar det slutgiltiga innehållets storlek — annars ramlar du rakt in i cumulative layout shift (CLS), och det är inte kul för någon.
3. Blanda cachade och ocachade fetch-anrop
Om en komponent gör två fetch-anrop där det ena är cachat och det andra inte, blir komponenten dynamisk i sin helhet. Dela upp dem i separata komponenter så att det cachade resultatet kan stanna kvar i det statiska skalet.
4. Middleware som läser cookies per förfrågan
Om din middleware.ts muterar förfrågan (t.ex. A/B-tester via cookies), påverkar det den statiska cachningen. Överväg att köra A/B-logiken inuti en dynamisk Suspense-gräns istället. Jag lärde mig det här på det hårda sättet.
PPR med generateStaticParams och ISR
PPR är fullt kompatibelt med generateStaticParams och revalidering. Det innebär att du kan:
- Förrendera alla produktskal redan vid build-tid.
- Revalidera skalet var 60:e minut med
export const revalidate = 3600. - Streama den personliga delen (pris, lager) per förfrågan.
// app/blog/[slug]/page.tsx
export const experimental_ppr = true;
export const revalidate = 3600; // skalet återgenereras varje timme
export async function generateStaticParams() {
const posts = await getAllPostSlugs();
return posts.map((slug) => ({ slug }));
}
Felsökning och debugging
Next.js 16 introducerar en trevlig ny flagga för att visualisera PPR-boundaries i development:
npm run dev -- --debug-ppr
I produktion kan du inspektera response-headers för att se om en rutt serveras från det statiska skalet:
x-nextjs-cache: HIT— statiskt skal serverat från CDN.x-nextjs-ppr: 1— rutten använder PPR.x-matched-path— den matchade App Router-rutten.
När ska du INTE använda PPR?
PPR är inte rätt lösning för allt. Undvik det när:
- Hela sidan är helt personlig (inget statiskt innehåll alls) — kör ren dynamisk rendering istället.
- Hela sidan är helt statisk (inga personliga delar) — ren SSG är både enklare och lika snabb.
- Du har väldigt få besökare per rutt — PPR-vinsten kommer från att CDN-cachen faktiskt slår igenom.
FAQ
Är Partial Prerendering stabilt i produktion?
I Next.js 15.3+ är PPR stabilt i incremental-läge, vilket innebär att du aktiverar det per rutt. Vercel kör det i produktion på riktigt stora kundbelastningar, och i Next.js 16 flyttas det mot att bli standard. För produktionsbruk rekommenderas fortfarande incremental-läget tills hela din kodbas är auditerad.
Vad är skillnaden mellan PPR och React Suspense?
React Suspense är det underliggande API:t som PPR använder för att markera dynamiska hål. Suspense ensamt i ren SSR ger streaming, men kräver fortfarande att servern renderar hela sidan per förfrågan. PPR lägger till ett förrenderingssteg vid build-tid som producerar ett cachebart statiskt skal — vilket Suspense inte klarar på egen hand.
Fungerar PPR med server actions?
Ja. Server actions är oberoende av renderingsstrategi — de kan anropas från både statiska och dynamiska delar av en PPR-sida. Efter en server action kan du anropa revalidatePath eller revalidateTag för att invalidera det statiska skalet vid behov.
Kan jag kombinera PPR med edge runtime?
Absolut. Ange export const runtime = 'edge' i din rutt för att köra den dynamiska delen på Edge, medan skalet fortfarande cachas i CDN. Det ger ännu lägre latens för dynamiska hål eftersom de körs geografiskt nära användaren.
Behöver jag ändra min befintliga Next.js-kod för att använda PPR?
Minimalt. Om du redan använder App Router med Server Components och Suspense-gränser räcker det oftast med att aktivera experimental.ppr: 'incremental' i next.config.js och lägga till export const experimental_ppr = true i de rutter du vill optimera. Den största förändringen brukar vara att flytta dynamiska API-anrop (cookies, headers) bakom Suspense-gränser istället för att ha dem direkt i sidkomponenten.
Slutsats
Partial Prerendering löser ett av de viktigaste arkitektoniska problemen i moderna webbapplikationer: hur kombinerar man CDN-snabb leverans med personlig, dynamisk data utan att välja bort det ena? För Next.js-team som redan använder App Router är steget till PPR relativt litet — men påverkan på Core Web Vitals är dramatisk, särskilt för TTFB och FCP.
Mitt råd? Börja med en enda rutt. Mät resultatet i verklig trafik. Utöka därefter. När du har en väl strukturerad komponentträd med tydliga Suspense-gränser blir PPR nästan gratis att slå på — och när du väl sett skillnaden i Lighthouse-rapporten vill du inte gå tillbaka.