Skip to main content
ASoc
Tutorial

Next.js Partial Prerendering: 678 Static Pages, 8 Dynamic Routes, Zero PPR

A fresh build generates 678 prerendered pages and 8 dynamic routes. None of the 8 mix a static shell with a dynamic hole — which is exactly the shape PPR needs to pay off.

The ASoc Team8 min read

Partial Prerendering (PPR) lets one route serve a static shell instantly while streaming in per-request holes marked by Suspense. A fresh build of this storefront generates 686 prerendered pages and renders exactly 8 routes dynamically — and PPR is enabled on none of them, because not one of those 8 routes is a static shell with a dynamic hole in it. Each is dynamic top to bottom, or not a page at all.

The short answer

Enable Partial Prerendering with experimental.ppr (or, on Next.js 16, Cache Components) and wrap the parts of a route that need per-request data in <Suspense> — the rest of the route prerenders as static HTML and the wrapped parts stream in after. It earns its keep on a route that mixes a mostly-static layout with one personalized section (a product page with a "recommended for you" strip, say). It buys nothing on a route that is either fully static already or fully dynamic already, which turns out to describe every dynamic route this codebase has.

What the build actually reports

npm run build on Next.js 16.2.9 (Turbopack) prints a route manifest, not an estimate:

Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/download
├ ƒ /api/webhooks/lemonsqueezy
├ ƒ /auth/callback
├ ○ /blog
├ ● /blog/[slug]            (269 static params)
├ ○ /blog/feed.xml
├ ƒ /dashboard
├ ƒ /dashboard/settings
├ ƒ /login
├ ƒ /reset-password
├ ƒ /signup
├ ● /templates/[slug]       (111 static params)
└ … 25 more static (○) routes

  Generating static pages using 3 workers (686/686)

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)
ƒ  (Dynamic)  server-rendered on demand

686 pages generated at build time; 8 routes marked ƒ. That split is the entire input PPR needs to be worth reaching for, and reading down the 8 is why it isn't.

The 8 dynamic routes, by what actually makes them dynamic

RouteWhy it's dynamicDoes it have a static shell worth prerendering?
/api/downloadA Route Handler — writes a rate-limited download_events row per callNo — it's JSON/binary, not HTML
/api/webhooks/lemonsqueezyA Route Handler — verifies an HMAC signature against the raw request bodyNo — same, not a rendered page
/auth/callbackA Route Handler doing a PKCE code exchange, then redirect()No — it never renders a page, only redirects
/dashboardcreateClient() reads the session cookie to fetch the signed-in buyer's ordersNo — every pixel is per-user; there's no shared shell to prerender
/dashboard/settingsSame session-cookie read, for account settingsNo — same reasoning
/loginsearchParams is read as a Promise, and an existing session redirects awayBarely — the form markup is static, but the whole route is three lines of chrome around one Server Action
/signupSame searchParams-as-Promise patternSame as /login
/reset-passwordSame pattern, plus a one-time recovery code in the query stringSame as /login

Two of those eight are Route Handlers that never render HTML at all — PPR is an App Router rendering feature, and there's no shell to speak of when the response is a webhook's 200 or a download's byte stream. /auth/callback only ever redirects. That leaves five actual pages, and every one of them is dynamic for its entire content, not for one widget inside otherwise-static chrome: a dashboard is nothing but per-user data, and a login form's only "dynamic" part is reading two search params before rendering three lines of static markup around a form. Splitting that into a static shell plus a Suspense-streamed hole would prerender essentially nothing and stream back essentially the whole page — the mechanism exists for the opposite shape.

Where PPR actually would have paid off, and why this catalog doesn't have that shape

The canonical PPR example is a product page: static description and images, with a personalized "your recent orders" or "price for your region" strip streamed in beside it. This storefront's own product pages — the 111 /templates/[slug] routes marked ● above — are the closest match to that shape in the whole app, and they're already fully static, generated once at build time via generateStaticParams with no per-request personalization inside them at all. DownloadMenu's owner-only edition picker, the one piece of that page that varies by signed-in buyer, is rendered client-side off useOwnedProducts() after the static HTML lands — a client-rendered island the browser fills in, not a server-streamed PPR hole. That's the same "static shell, dynamic piece" shape PPR targets, solved with a different, older mechanism (a "use client" boundary) that this codebase already had for Next.js Suspense reasons before Cache Components existed as an option.

The one config line that's easy to confuse with PPR

src/app/blog/[slug]/page.tsx sets export const dynamicParams = false — a different flag, worth distinguishing because it's the kind of line a search for "next.js partial prerendering" turns up next to. dynamicParams controls what happens when a URL matches the dynamic segment but wasn't in generateStaticParams's list: false means Next.js serves a 404 instead of rendering it on demand, which is the opposite instinct from PPR (PPR is about serving something fast while the rest streams; dynamicParams = false is about refusing to render a path at all outside the build-time set). This repo sets it because every blog slug is known at build time — 269 of them — and a mistyped or retired slug should 404 immediately rather than trigger an on-demand render attempt for a page that was never going to exist.

PPR vs. what this codebase actually does

Partial PrerenderingThis codebase's approach
Mixed static+dynamic routeStatic shell prerendered, dynamic parts stream via SuspenseNot needed — the 111 product pages are 100% static; the one per-buyer piece (DownloadMenu) is a client component reading useOwnedProducts() after hydration
Fully dynamic route (dashboard)Would still render on demand — PPR has no static shell to offer hereRendered dynamically, in full, because createClient() reads the session cookie
Fully static route (/, /pricing, /templates)Already the fast path without PPRAlready the fast path without PPR
Config requiredexperimental.ppr / Cache Components, plus Suspense boundaries added deliberatelyZero — next.config.ts sets none of it

Troubleshooting

SymptomCauseFix
PPR flag enabled but nothing seems to changeNo route in the app actually mixes static and dynamic content in one treeConfirm you have the shape PPR targets before enabling it — a fully static or fully dynamic route sees no benefit
A page marked ƒ in the build output was expected to be staticReading cookies(), headers(), or an awaited searchParams anywhere in the tree opts the whole route out of static rendering, silentlyMove the dynamic read behind a Suspense boundary if only part of the page needs it, or accept the route as fully dynamic if it genuinely is
Suspense boundary added for PPR doesn't actually streamThe fallback and the real content resolve at nearly the same speed, so there's nothing to seePPR's win is latency hiding — it has nothing to hide when the dynamic part is already fast
Confusing PPR with next/dynamicBoth defer something, but one is a data-fetching/rendering strategy and the other is a client-side code-splitting APInext/dynamic lazy-loads a component's JavaScript; PPR changes when a route's HTML is generated

Frequently asked questions

Is Partial Prerendering stable in Next.js 16? It shipped as experimental behind experimental.ppr and has graduated alongside Next.js 16's Cache Components model. Either way, turning it on only helps a route that actually mixes static and per-request content — it isn't a general performance switch.

Does PPR replace generateStaticParams? No. generateStaticParams decides which dynamic-segment pages get prerendered at all (this storefront uses it for all 111 product pages and 263 blog posts); PPR is about mixing static and dynamic within one already-rendered route.

Why does this storefront have zero PPR usage with 111 product pages that could theoretically use it? Because the one part of a product page that varies by viewer — the owner's download options — is already solved client-side, after the static HTML ships. Retrofitting that into a server-streamed PPR hole would swap a working, simpler mechanism for a more complex one with no output difference a user would notice.

How do I tell if my own route would benefit from PPR? Check whether it's mixed: does most of the page not depend on the request, while one section genuinely does? If the whole page needs per-request data (a dashboard) or none of it does (a marketing page), PPR has nothing to split.

Where to take this next

Static Rendering in the Next.js App Router is the companion piece: it audits which of the four App Router features silently turn a page dynamic in the first place, which is the read this post starts from. React Suspense covers why this codebase's own Suspense boundary count is zero, and how DownloadMenu's client-side ownership check ended up solving the "static shell, one dynamic piece" problem a different way.

Templates in this post

ASoc Remit, ASoc Script and ASoc Seeker are fully static landing pages — no dashboard, no session cookie, nothing in the tree that would opt a route out of prerendering.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial8 min read

Next.js Prefetch: What Fires on Viewport Entry, Not Just on Click

Link prefetches on scroll into view, before anyone clicks. This storefront keeps one href out of Link entirely because a prefetch there would burn a buyer's download quota.

Read more
Tutorial10 min read

Product Filtering in Next.js: Why Filters Belong in the URL

Filters in useState cannot be shared, bookmarked, or server-rendered. How to read them from searchParams — and which filtered URLs to let Google crawl.

Read more
Tutorial11 min read

A Product Image Gallery in Next.js That Google Can Actually See

Virtualizing a four-slide carousel removes three product images from the HTML. Mount every slide, starve the off-screen ones, and the ARIA that a carousel actually needs.

Read more