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.
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
| Route | Why it's dynamic | Does it have a static shell worth prerendering? |
|---|---|---|
/api/download | A Route Handler — writes a rate-limited download_events row per call | No — it's JSON/binary, not HTML |
/api/webhooks/lemonsqueezy | A Route Handler — verifies an HMAC signature against the raw request body | No — same, not a rendered page |
/auth/callback | A Route Handler doing a PKCE code exchange, then redirect() | No — it never renders a page, only redirects |
/dashboard | createClient() reads the session cookie to fetch the signed-in buyer's orders | No — every pixel is per-user; there's no shared shell to prerender |
/dashboard/settings | Same session-cookie read, for account settings | No — same reasoning |
/login | searchParams is read as a Promise, and an existing session redirects away | Barely — the form markup is static, but the whole route is three lines of chrome around one Server Action |
/signup | Same searchParams-as-Promise pattern | Same as /login |
/reset-password | Same pattern, plus a one-time recovery code in the query string | Same 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 Prerendering | This codebase's approach | |
|---|---|---|
| Mixed static+dynamic route | Static shell prerendered, dynamic parts stream via Suspense | Not 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 here | Rendered dynamically, in full, because createClient() reads the session cookie |
Fully static route (/, /pricing, /templates) | Already the fast path without PPR | Already the fast path without PPR |
| Config required | experimental.ppr / Cache Components, plus Suspense boundaries added deliberately | Zero — next.config.ts sets none of it |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| PPR flag enabled but nothing seems to change | No route in the app actually mixes static and dynamic content in one tree | Confirm 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 static | Reading cookies(), headers(), or an awaited searchParams anywhere in the tree opts the whole route out of static rendering, silently | Move 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 stream | The fallback and the real content resolve at nearly the same speed, so there's nothing to see | PPR's win is latency hiding — it has nothing to hide when the dynamic part is already fast |
Confusing PPR with next/dynamic | Both defer something, but one is a data-fetching/rendering strategy and the other is a client-side code-splitting API | next/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.
