Mikä on Partial Prerendering?
Partial Prerendering (PPR) on Next.js:n renderöintistrategia, joka ratkaisee yhden web-kehityksen ikuisuuskysymyksistä: pitääkö sivun olla staattinen vai dynaaminen? PPR:n vastaus on yksinkertainen — molempia.
Sen sijaan, että joutuisit valitsemaan koko sivulle joko SSG:n, SSR:n tai ISR:n, PPR mahdollistaa molemmat samalla sivulla. Staattinen shell palvellaan välittömästi CDN:ltä, ja dynaamiset osat striimataan palvelimelta samassa HTTP-vastauksessa.
Käytännössä tämä näkyy siten, että käyttäjä näkee sivun rakenteen — navigaation, otsikot, asettelun — millisekunneissa. Personoitu sisältö, kuten käyttäjäprofiilit, ostoskorin tiedot tai reaaliaikaiset hinnat, täydentyy sitä mukaa kuin data valmistuu palvelimella. Aika vaikuttavaa, eikö?
Next.js 16:n myötä PPR on siirtynyt kokeellisesta vakaaksi ominaisuudeksi, ja se on käytännössä oletusrenderöintimalli uusille sovelluksille.
Miten PPR eroaa perinteisestä streamingistä?
Tämä on yksi yleisimmistä kysymyksistä, joita PPR:stä tulee. Perinteisessä React-streamingissä (Suspense ilman PPR:ää) koko sivu renderöidään palvelimella jokaisen pyynnön yhteydessä. Suspense-rajaukset mahdollistavat rinnakkaisen datanhaun, mutta mikään osa sivusta ei ole esirenderöity — kaikki lasketaan request-aikana.
PPR toimii toisin:
- Build-aikana: Next.js renderöi kaiken, mikä ei riipu pyynnöstä (cookies, headers, hakuparametrit), staattiseksi HTML:ksi.
- Request-aikana: Vain Suspense-rajausten sisällä olevat dynaamiset komponentit renderöidään ja striimataan.
Eli TTFB (Time to First Byte) on CDN-nopea, koska staattinen shell palvellaan suoraan reunapalvelimelta. Dynaaminen sisältö täydentyy taustalla ilman erillistä HTTP-pyyntöä. Yksi yhteys, kaikki samassa paketissa.
| Ominaisuus | Perinteinen Streaming | Partial Prerendering |
|---|---|---|
| Staattinen osa | Renderöidään request-aikana | Esirenderöidään build-aikana |
| TTFB | Riippuu palvelimen nopeudesta | CDN-nopea |
| Dynaaminen osa | Striimataan request-aikana | Striimataan request-aikana |
| HTTP-pyynnöt | 1 (kaikki palvelimelta) | 1 (shell + stream samassa) |
PPR:n käyttöönotto
PPR:n aktivointitapa riippuu Next.js-versiostasi. Hyvä uutinen on, että molemmissa tapauksissa PPR toimii automaattisesti Suspense-rajausten kanssa — sinun ei tarvitse muuttaa komponenttikoodiasi.
Next.js 15: Inkrementaalinen käyttöönotto
Next.js 15:ssä PPR on vielä kokeellinen ominaisuus, jonka voit ottaa käyttöön reitti kerrallaan:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental',
},
}
export default nextConfig
Lisää sitten jokaiselle PPR-reitille eksplisiittinen opt-in:
// app/tuotteet/[id]/page.tsx
export const experimental_ppr = true
export default async function TuoteSivu({ params }: { params: { id: string } }) {
// ...
}
Next.js 16: Cache Components
Next.js 16:ssa homma on paljon suoraviivaisempaa. PPR aktivoidaan Cache Components -asetuksella, joka korvaa vanhan kokeellisen lipun:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
Siinä se — PPR on nyt käytössä kaikille reiteille automaattisesti. Kaikki, mikä ei käytä request-aikaista dataa (cookies, headers), esirenderöidään build-aikana.
Suspense-rajaukset: PPR:n ydinmekanismi
React Suspense on PPR:n tärkein rakennuspalikka. Se määrittelee rajan staattisen ja dynaamisen sisällön välillä: kaikki Suspense-rajauksen ulkopuolella on staattista, kaikki sisällä on dynaamista. Näin yksinkertaista se on.
Suspense-rajaus vaatii aina fallback-propin — komponentin, joka näytetään dynaamisella alueella, kunnes varsinainen sisältö on valmis:
import { Suspense } from 'react'
import { Tuotetiedot } from './tuotetiedot'
import { Arvostelut, ArvosteluSkeleton } from './arvostelut'
import { Kayttajapalkki, KayttajapalkkiSkeleton } from './kayttajapalkki'
export default function TuoteSivu() {
return (
<main>
{/* Staattinen: esirenderöidään build-aikana */}
<Tuotetiedot />
{/* Dynaaminen: striimataan request-aikana */}
<Suspense fallback={<KayttajapalkkiSkeleton />}>
<Kayttajapalkki />
</Suspense>
{/* Dynaaminen: striimataan request-aikana */}
<Suspense fallback={<ArvosteluSkeleton />}>
<Arvostelut />
</Suspense>
</main>
)
}
Tässä esimerkissä <Tuotetiedot /> on täysin staattinen komponentti — se esirenderöidään HTML:ksi build-aikana. <Kayttajapalkki /> ja <Arvostelut /> ovat dynaamisia, koska ne käyttävät request-aikaista dataa (esim. cookieita tai tietokantahakuja).
Mikä tekee komponentista dynaamisen?
Komponentti muuttuu dynaamiseksi, kun se käyttää mitä tahansa request-aikaista API:a:
cookies()— evästeiden lukeminenheaders()— pyyntöotsikoiden lukeminensearchParams— URL:n hakuparametritfetch(..., { cache: 'no-store' })— välimuistittamaton datahakuconnection()— yhteyden tietojen käyttö
Jos komponentti ei käytä mitään näistä, se on automaattisesti staattinen. Ei tarvitse tehdä mitään erikoista.
Käytännön esimerkki: Verkkokaupan tuotesivu
Nyt päästään siihen, missä PPR todella näyttää voimansa. Tarkastellaan realistista verkkokaupan tuotesivua. Suurin osa sivusta on identtinen kaikille käyttäjille — tuotekuvat, kuvaus, tekniset tiedot. Vain muutama osio on personoitua.
// app/tuotteet/[id]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { haeTuote } from '@/lib/tuotteet'
import { TuoteKuvat } from './tuote-kuvat'
import { TuoteKuvaus } from './tuote-kuvaus'
import { HintaJaSaatavuus, HintaSkeleton } from './hinta-saatavuus'
import { Suositukset, SuosituksetSkeleton } from './suositukset'
import { OstoskoriNappi, OstoskoriSkeleton } from './ostoskori-nappi'
export async function generateStaticParams() {
const tuotteet = await haeTuote()
return tuotteet.map((t) => ({ id: t.id }))
}
export default async function TuoteSivu({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const tuote = await haeTuote(id)
if (!tuote) notFound()
return (
<article>
{/* Staattinen shell */}
<TuoteKuvat kuvat={tuote.kuvat} />
<TuoteKuvaus tuote={tuote} />
{/* Dynaaminen: hinta riippuu käyttäjän sijainnista ja valuutasta */}
<Suspense fallback={<HintaSkeleton />}>
<HintaJaSaatavuus tuoteId={id} />
</Suspense>
{/* Dynaaminen: ostoskorin tila riippuu sessiosta */}
<Suspense fallback={<OstoskoriSkeleton />}>
<OstoskoriNappi tuoteId={id} />
</Suspense>
{/* Dynaaminen: personoidut suositukset */}
<Suspense fallback={<SuosituksetSkeleton />}>
<Suositukset tuoteId={id} />
</Suspense>
</article>
)
}
Ja tässä on esimerkki yhdestä dynaamisesta komponentista — hintakomponentista, joka lukee käyttäjän evästeitä:
// app/tuotteet/[id]/hinta-saatavuus.tsx
import { cookies } from 'next/headers'
import { haeHintaTiedot } from '@/lib/hinnat'
export async function HintaJaSaatavuus({ tuoteId }: { tuoteId: string }) {
const cookieStore = await cookies()
const valuutta = cookieStore.get('valuutta')?.value ?? 'EUR'
const alue = cookieStore.get('alue')?.value ?? 'FI'
const { hinta, saatavuus, toimitusaika } = await haeHintaTiedot(
tuoteId,
valuutta,
alue
)
return (
<div>
<p className="text-2xl font-bold">
{hinta.toFixed(2)} {valuutta}
</p>
<p>Saatavuus: {saatavuus}</p>
<p>Arvioitu toimitusaika: {toimitusaika}</p>
</div>
)
}
export function HintaSkeleton() {
return (
<div className="animate-pulse">
<div className="h-8 w-32 bg-gray-200 rounded" />
<div className="h-4 w-48 bg-gray-200 rounded mt-2" />
<div className="h-4 w-40 bg-gray-200 rounded mt-2" />
</div>
)
}
Skeleton-komponenttien suunnittelu
Hyvät fallback-komponentit ovat olennainen osa PPR-kokemusta. Ne näkyvät käyttäjälle osana staattista shelliä, kunnes dynaaminen sisältö on valmis. Rehellisesti sanottuna huono skeleton-suunnittelu voi pilata kaikki PPR:n edut.
Muutama tärkeä periaate:
- Vastaa lopullisen sisällön rakennetta: Skeletonin tulisi olla samanmuotoinen kuin valmis komponentti. Tämä estää layout shift -ongelmia (CLS).
- Käytä animaatioita harkiten:
animate-pulseon hyvä perusvalinta, mutta liikaa animaatioita häiritsee. - Pidä ne kevyinä: Skeleton on osa staattista shelliä, joten sen HTML sisältyy esirenderöityyn sisältöön. Vältä raskaita komponentteja.
- Testaa hitailla yhteyksillä: Käytä selaimen DevToolseja simuloimaan hidasta yhteyttä ja tarkista, näyttääkö skeleton luonnolliselta.
Suspense-rajausten sijoittelu
Suspense-rajausten oikea sijoittelu on rehellisesti sanottuna PPR:n tärkein suunnittelupäätös. Kaksi ääripäätä ovat molemmat ongelmallisia.
Liian laajat rajaukset
// Huono: koko sivun sisältö on dynaamista
<Suspense fallback={<SivuSkeleton />}>
<Navigaatio />
<Tuotetiedot />
<Kayttajapalkki />
<Arvostelut />
</Suspense>
Tämä tekee koko sivusta dynaamisen. PPR:stä ei ole mitään hyötyä, koska mikään ei esirenderöidy.
Liian kapeat rajaukset
// Varoitus: liikaa rajauksia lisää kompleksisuutta
<Suspense fallback={<A />}><KomponenttiA /></Suspense>
<Suspense fallback={<B />}><KomponenttiB /></Suspense>
<Suspense fallback={<C />}><KomponenttiC /></Suspense>
<Suspense fallback={<D />}><KomponenttiD /></Suspense>
<Suspense fallback={<E />}><KomponenttiE /></Suspense>
Liikaa rajauksia lisää ylläpitokompleksisuutta ja voi aiheuttaa visuaalista "popcorn-efektiä" — sisältöä ilmestyy satunnaisesti eri kohtiin sivua ja kokemus on hajanainen.
Optimaalinen lähestymistapa
Paras ratkaisu on asettaa Suspense-rajaukset mahdollisimman lähelle dynaamista komponenttia ja ryhmitellä loogisesti yhteen kuuluvat dynaamiset komponentit saman rajauksen sisälle:
// Hyvä: staattinen shell on laaja, dynaamiset osat rajattu
<Navigaatio />
<Tuotetiedot />
<Suspense fallback={<KayttajaOsioSkeleton />}>
<Kayttajapalkki />
<OstoskoriNappi />
</Suspense>
<Suspense fallback={<ArvosteluSkeleton />}>
<Arvostelut />
</Suspense>
PPR ja use cache -direktiivi
Next.js 16:n use cache -direktiivi on PPR:n luonnollinen pari. Voit käyttää sitä funktioissa tai komponenteissa, jotka tekevät raskaita laskentoja tai tietokantahakuja mutta eivät riipu request-aikaisesta datasta:
// app/tuotteet/[id]/suositukset.tsx
'use cache'
import { cacheLife, cacheTag } from 'next/cache'
import { haeKategorianTuotteet } from '@/lib/tuotteet'
export async function SuositeltuTuotteet({ kategoriaId }: { kategoriaId: string }) {
cacheLife('hours')
cacheTag('suositukset', `kategoria-${kategoriaId}`)
const tuotteet = await haeKategorianTuotteet(kategoriaId)
return (
<section>
<h3>Suositellut tuotteet</h3>
<ul>
{tuotteet.map((t) => (
<li key={t.id}>{t.nimi}</li>
))}
</ul>
</section>
)
}
Tässä suositukset välimuistitetaan tunnin ajaksi ja ne voidaan mitätöidä revalidateTag('suositukset') -kutsulla. PPR:n kannalta komponentti on staattinen (koska se käyttää use cache eikä request-aikaista dataa), joten se sisältyy esirenderöityyn shelliin. Aika näppärää.
Dashboard-esimerkki: PPR ja rinnakkaiset datakyselyt
Toinen yleinen PPR-käyttötapaus on dashboard. Osassa dashboardia data on käyttäjäkohtaista, osa taas on globaalia ja samaa kaikille.
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { YleisetTilastot } from './yleiset-tilastot'
import { KayttajanTilaukset, TilauksetSkeleton } from './kayttajan-tilaukset'
import { Ilmoitukset, IlmoituksetSkeleton } from './ilmoitukset'
import { AktiviteettiKaavio } from './aktiviteetti-kaavio'
export default function Dashboard() {
return (
<div className="grid grid-cols-2 gap-6">
{/* Staattinen: samat kaikille */}
<YleisetTilastot />
<AktiviteettiKaavio />
{/* Dynaaminen: käyttäjäkohtainen */}
<Suspense fallback={<TilauksetSkeleton />}>
<KayttajanTilaukset />
</Suspense>
<Suspense fallback={<IlmoituksetSkeleton />}>
<Ilmoitukset />
</Suspense>
</div>
)
}
Lopputulos? Dashboard latautuu silmänräpäyksessä. Yleiset tilastot ja kaaviot näkyvät heti CDN:ltä, ja käyttäjän omat tilaukset sekä ilmoitukset täydentyvät muutamassa sadassa millisekunnissa. Käyttäjä ei juuri huomaa mitään latausta.
Milloin PPR ei ole oikea valinta?
PPR sopii useimpiin tilanteisiin, mutta on muutama tapaus, joissa muut strategiat toimivat paremmin:
- Täysin staattinen sisältö: Jos sivulla ei ole lainkaan dynaamista dataa (blogi, dokumentaatio), tavallinen SSG riittää. PPR ei haittaa, mutta se ei myöskään tuo lisäarvoa.
- Täysin dynaaminen sivu: Jos koko sivun sisältö riippuu requestista (esim. hakusivu, jossa kaikki määräytyy hakutermeistä), PPR:stä ei juuri ole hyötyä — staattista shelliä ei yksinkertaisesti synny.
- Static Export: PPR vaatii palvelimen renderöimään dynaamisia osia. Jos käytät
output: 'export', PPR ei ole käytettävissä. - Edge Runtime: ISR ja PPR vaativat Node.js-runtimen. Edge-runtimessa PPR:n staattinen shell toimii, mutta ISR-revalidointi ei.
PPR:n suorituskykyvaikutukset
PPR parantaa suorituskykyä usealla mittarilla, ja erot voivat olla merkittäviä:
- TTFB (Time to First Byte): Staattinen shell palvellaan CDN:ltä, joten TTFB on tyypillisesti alle 50 ms.
- FCP (First Contentful Paint): Käyttäjä näkee sisältöä välittömästi, koska staattinen HTML ei odota palvelimen laskentaa.
- CLS (Cumulative Layout Shift): Hyvin suunnitellut skeleton-komponentit estävät layout shift -ongelmia.
- LCP (Largest Contentful Paint): Jos suurin sisältöelementti on osa staattista shelliä (esim. tuotekuva), LCP on erittäin nopea.
Vercelille deployattaessa PPR:n staattinen shell palvellaan globaalisti reunapalvelimilta. Dynaamiset osat renderöidään lähimmällä palvelinalueella, ja koko vastaus lähetetään yhdessä HTTP-pyynnössä streamingin avulla. Omalla palvelimella hyöty riippuu tietysti infrastruktuurista, mutta perusperiaate on sama.
Debuggaus ja testaus
PPR:n toiminnan varmistaminen kehitysympäristössä vaatii muutamia huomioita. Tässä on yksi asia, joka monilta menee ohi.
Kehitysympäristö vs. tuotanto
Kehitysympäristössä (next dev) PPR-käyttäytyminen ei näy, koska kaikki renderöidään request-aikana. Tämä hämää helposti — testaa PPR:ää aina tuotantomaisesti:
next build && next start
Välimuistin seuranta
Tarkista x-nextjs-cache-vastausotsikko selaimen DevToolsissa:
HIT— palveltu välimuistista (staattinen shell)STALE— palveltu välimuistista, revalidoidaan taustallaMISS— renderöity tuoreena
Virhetilanteet
Jos näet virheilmoituksen "Uncached data was accessed outside of <Suspense>", se tarkoittaa, että dynaaminen komponentti ei ole Suspense-rajauksen sisällä. Ratkaisu on yksinkertainen: kääri dynaaminen komponentti <Suspense>-elementtiin tai merkitse se use cache -direktiivillä.
Yhteenveto: Renderöintistrategian valinta vuonna 2026
PPR on Next.js:n vastaus pitkäaikaiseen ongelmaan: miten yhdistää staattisen sivuston nopeus ja dynaamisen sovelluksen joustavuus? Tässä on yksinkertainen päätöspuu, joka auttaa valitsemaan oikean strategian:
- Sisältö ei muutu koskaan → SSG (oletus, ei tarvitse mitään konfiguraatiota)
- Sisältö muuttuu ajoittain → ISR (
revalidate-asetus taiuse cache+cacheLife) - Osa sivusta on personoitua → PPR (Suspense-rajaukset dynaamisten osien ympärille)
- Koko sivu on personoitu → SSR (
cache: 'no-store'tai dynaaminen data)
Omasta kokemuksesta voin sanoa, että useimmat tuotantosovellukset hyötyvät PPR:stä. Harva sivu on täysin staattinen tai täysin dynaaminen. Navigaatio, footer, tuotetiedot ja artikkelin runko ovat lähes aina staattisia — personointi koskee vain pientä osaa sivusta. Ja se on juuri se pieni osa, jossa PPR loistaa.
Usein kysytyt kysymykset
Toimiiko PPR ilman Vercelia?
Kyllä. PPR on Next.js:n ominaisuus, ei Vercel-spesifi. Se toimii missä tahansa Node.js-palvelimella, joka tukee next start -komentoa. Vercel tarjoaa kuitenkin lisäetuja, kuten globaalin reunaverkon staattiselle shellille ja automaattisen välimuistin hallinnan.
Voinko käyttää PPR:ää ja ISR:ää samassa sovelluksessa?
Ehdottomasti. PPR ja ISR täydentävät toisiaan hienosti. ISR hallitsee staattisen shellin revalidointia (esim. tuotteen kuvaus päivitetään CMS:stä), kun taas PPR:n dynaamiset osat renderöidään aina tuoreena. Voit käyttää use cache -direktiiviä ja cacheTag-tunnisteita kontrolloimaan, mitkä osat shellistä revalidoidaan ja milloin.
Kuinka paljon PPR parantaa suorituskykyä?
Se riippuu sivun rakenteesta. Sivuilla, joissa suurin osa sisällöstä on staattista (esim. verkkokaupan tuotesivu), TTFB voi parantua 50–80 %. Täysin dynaamisilla sivuilla ero on marginaalinen. Suurin hyöty tulee siitä, että käyttäjä näkee merkityksellistä sisältöä nopeammin — vaikka sivun kokonaislatausaika olisi teknisesti sama.
Tarvitseeko Suspense-fallbackien olla tarkkoja?
Kyllä, ja tämä on tärkeä pointti. Jos skeleton vastaa lopullisen sisällön muotoa ja kokoa, layout shift on minimaalinen. Jos fallback on pelkkä spinner tai tyhjä tila, käyttäjä kokee visuaalisen hypyn sisällön latautuessa. Suosi aina rakenteellisia skeletoneja spinnerien sijaan.
Miten PPR vaikuttaa SEO:on?
PPR on erinomainen SEO:n kannalta. Staattinen shell sisältää sivun pääsisällön — otsikot, kuvaukset, kuvat — joka on hakukoneille näkyvissä välittömästi. Dynaamiset osat striimataan HTML:nä (ei client-side JavaScript), joten ne ovat myös indeksoitavissa. Hakukoneet näkevät valmiin sivun ilman, että niiden tarvitsee suorittaa JavaScriptiä.