Skip to content
Blue Nebula

Next.js

Next.js 15 PPR: when it shines and when it bites

When Partial Prerendering in Next.js 15 pays off and when it gets in the way, with a decision tree for choosing between SSR, ISR and PPR.

By Luna R.Published Updated 7 min read

Next.js 15 partial prerendering (PPR) pays off on pages that are mostly static with one or two dynamic regions: the static shell is served immediately and the dynamic parts stream in behind Suspense boundaries[1][2]. It bites when dynamic content fills most of the screen, or when those boundaries are drawn in the wrong places.

In Next.js 15, PPR is experimental and you can adopt it one route at a time, which keeps the risk contained[3][7]. Next.js 16 removed the experimental flag and folded PPR into Cache Components, so if you’re on 16, read the last section before you copy any config from this post.

What PPR does on a real page

Picture a landing page where the hero and testimonials are the same for everyone, but the pricing panel depends on the visitor’s segment or location. You want the shared parts cached and delivered fast everywhere, and you can’t safely cache one pricing panel for every visitor. Partial prerendering ships the prerendered hero and testimonials right away, then streams in the pricing panel as soon as its data is ready. First paint comes sooner, and the static parts aren’t re-rendered on every request, a saving that adds up at scale[1][4].

The mechanism is React’s <Suspense>. You wrap each dynamic component in a boundary and give it a fallback. Next.js prerenders the static parts along with those fallbacks, at build time or when the page is revalidated, then swaps in the dynamic content when it’s ready[1][2][4].

When Next.js 15 PPR shines

PPR works best when a page is mostly static and its dynamic regions can arrive a moment later, like a stock-status box on a product page or a banner that changes by location. Visitors see real content right away and the dynamic parts fill in shortly after. Online stores are full of pages like this: on Cypress Clothiers, a headless Shopify store I built with a Next.js front end, static product pages and a dynamic cart live side by side.

It also helps with a global audience. On a host that supports it, the static shell is cached at the edge, close to visitors, and only the dynamic regions go back to your origin[1][6].

And it suits teams that change pages often. Clearly bounded regions let you change each region’s data strategy on its own, with less of the all-or-nothing coupling that turns small edits into regressions. Keeping stable layout apart from volatile data also makes routes easier to read and safer to refactor.

When PPR bites

Everything depends on where the boundaries go. Poorly placed Suspense boundaries lead to awkward loading states or unexpected client-side rendering. Calling useSearchParams without a boundary, for example, opts the entire page into client-side rendering, which can leave it blank until the JavaScript has loaded[5].

Library support is uneven. Some data libraries label Suspense for data fetching as experimental (TanStack Query’s v4 docs do), which can cause edge cases if you rely on them heavily[8]. PPR works without them. If your app depends on one, pin its version and isolate the components that use it, then cover those components with integration tests and error boundaries.

Monitoring gets harder too. Parts of the page arrive later, so a single load-time number no longer describes what visitors see.

SSR, ISR and PPR side by side

Each mode fits a different kind of page.

ModeBest forBenefitsTrade-offs
SSRHighly personalized or real-time data on every requestAccurate, request-specific content; simple mental modelHigher infrastructure cost at scale; slower first byte during peaks
ISRContent that’s mostly static with controlled staleness windowsCDN-level speed; amortized compute costsStaleness risk within the revalidate window; cache invalidation considerations[9]
PPRMixed pages: static shell + dynamic sectionsFaster perceived load without making the whole page dynamic; incremental adoption in Next.js 15[1][3]Requires careful Suspense boundaries and more monitoring; library compatibility varies[2][8]

ISR usually has the fastest first byte, because a fresh page is already cached and goes out without any rendering[9]. PPR is close behind: its prerendered shell is sent immediately and the dynamic parts stream into the same response[7]. SSR has to wait for the page’s data before the first byte. Streaming with loading.js or Suspense lets it send a layout sooner[4], but that layout is still rendered on every request.

Drawing Suspense boundaries

A good Suspense boundary wraps one cohesive dynamic unit, with a fallback that holds the layout steady. Think of a RecommendationsGrid whose placeholder keeps every card at its final size. Avoid wrapping half of a hero banner or grouping unrelated regions under one boundary, since both make the page jump as content arrives.

Hooks that read the URL need the same care. Wrap the smallest component that calls useSearchParams in its own boundary, so the rest of the page stays in the static shell and avoids a full client-side rendering bailout[5]. React’s documentation explains how Suspense shows a fallback and then reveals the finished UI once the data has loaded[2].

A decision tree for SSR, ISR and PPR

The choice mostly comes down to how long the content stays valid and how much of it is specific to each visitor. Use this flow as a starting point, then adjust it for how much staleness you can accept and how quickly your data sources respond.

Fig. 01Choosing between SSR, ISR and PPR
  1. A page route

  2. Is most of the content stable for minutes or longer?

    No

    Use SSR

    Speed up the data source and cache what you can upstream

  3. Yes

    Does user-specific content have to appear instantly?

    No

    Use ISR

    Set the revalidate window to how fresh the page must be

  4. Yes

    Can the dynamic parts sit behind Suspense boundaries?

    No

    Use SSR

  5. Yes

    Use PPR

    A static shell, with the dynamic parts streamed in

Real pages often fit more than one branch. Product pages with shared stock levels might do well with ISR plus on-demand revalidation for best sellers, while signed-in dashboards often suit PPR: the navigation and overview cards render immediately and the data-heavy widgets stream in.

As a rule of thumb:

  • Choose ISR when some staleness is acceptable and the content is the same for everyone. It’s the fastest option and uses the least compute.
  • Choose SSR when every request needs its own data immediately and you can afford to keep backend latency low.
  • Choose PPR when you want a fast, cacheable shell with dynamic inserts that don’t hold up first paint. The more of the viewport the stable part covers, the more PPR tends to pay off.

Rolling PPR out in Next.js 15

I’d start small. In Next.js 15, setting experimental.ppr to 'incremental' lets you opt individual layouts and pages into PPR with a route segment export, so you can try it on a few high-impact routes and get used to drawing boundaries before you convert complex ones[3][7].

JavaScript
// Next.js 15 (canary): next.config.js
module.exports = {
  experimental: {
    ppr: 'incremental',
  },
}
TSX
// Next.js 15: app/pricing/page.tsx opts in to PPR
export const experimental_ppr = true

Put real work into fallbacks. A layout shift or a spinner that looks broken is the quickest way to make a PPR page feel worse than the version it replaced. Use skeletons that keep the final height and make it obvious something is loading.

Then measure each stage. Keep tracking first byte and first paint, and add the time each key region finishes loading, because with streaming “done” is a series of moments. Check that caching and fallbacks behave the same across regions and devices.

What changed in Next.js 16

Next.js 16 removed both experimental.ppr and the experimental_ppr route segment config. PPR is now part of Cache Components: you turn on the top-level cacheComponents option, and PPR becomes the default rendering behavior in the App Router.

TypeScript
// Next.js 16: next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

Switching takes more than renaming a flag. With Cache Components, data fetching is dynamic by default, and caching is opt-in through the use cache directive and cacheLife, which replace the dynamic, revalidate and fetchCache segment configs. In the decision tree above, the ISR revalidate window becomes a cacheLife setting. Uncached data outside a Suspense boundary can also fail the build.

The Next.js 16 upgrade guide also says PPR in 16 works differently from the Next.js 15 canaries. It tells anyone using PPR today to stay on their current canary, and points to the Migrating to Cache Components guide for the move. You can still convert one route at a time: a codemod removes the experimental_ppr exports, and export const instant = false opts a segment out of validation until you get to it. The rest of the upgrade is in Next.js 16 vs 15: breaking changes and how to fix them.

The trade-off this post describes is the same in both versions. PPR earns its place when most of the screen can be prerendered and the personal parts can wait a moment behind a Suspense boundary: you get a faster first impression than SSR without forcing those parts into a cache. When dynamic content fills the viewport, SSR is simpler, and when nothing on the page is personal, ISR is hard to beat.

Sources

01

Next.js Docs. (2025). Getting Started: Partial Prerendering (App Router). Retrieved from https://nextjs.org/docs/app/getting-started/partial-prerendering

02

React Docs. (2025). <Suspense> – React. Retrieved from https://react.dev/reference/react/Suspense

03

Next.js Docs. (2025). next.config.js: ppr (incremental adoption). Retrieved from https://nextjs.org/docs/app/api-reference/config/next-config-js/ppr

04

Next.js Docs. (2025). File-system conventions: loading.js (Streaming with Suspense). Retrieved from https://nextjs.org/docs/app/api-reference/file-conventions/loading

05

Next.js Docs. (2025). Missing Suspense boundary with useSearchParams. Retrieved from https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout

06

Vercel Blog. (2023).

Partial prerendering: Building towards a new default rendering model for web applications

. Retrieved from https://vercel.com/blog/partial-prerendering-with-next-js-creating-a-new-default-rendering-model

07

Next.js Blog. (2024). Next.js 15 RC (Incremental adoption of PPR). Retrieved from https://nextjs.org/blog/next-15-rc

08

TanStack Query Docs. (2025). Suspense (Experimental). Retrieved from https://tanstack.com/query/v4/docs/react/guides/suspense

09

Vercel Docs. (2025). Incremental Static Regeneration (ISR). Retrieved from https://vercel.com/docs/incremental-static-regeneration

Written by

Luna R.

Founder and engineer at Blue Nebula, a one-person studio in Daly City, CA, that builds web apps, Shopify stores and AI features. Top Rated Plus on Upwork with 3,700+ hours of client work.

More about me

Building on Next.js?

Tell me what you’re building.

mail@bluenebula.dev
Start a project