All articles
Web DevelopmentMay 13, 2026 6 min read

Partial Prerendering in Next.js: When the Hype Meets Real Apps

Partial Prerendering promises the speed of static with the freshness of dynamic. Here's what actually happens when you ship it past a marketing site, and where it quietly bites.

Partial Prerendering in Next.js: When the Hype Meets Real Apps

Partial Prerendering (PPR) is one of those features that demos beautifully and then makes you read the source code at 2am. The pitch is genuinely good: serve a static shell instantly, stream in the dynamic bits, no full-route opt-out. The reality, once you move past a landing page and into an authenticated product, has a lot more sharp edges.

This is what we've learned wiring PPR into real Next.js App Router codebases — what it does well, what it quietly costs you, and how to structure a route so it actually ships.

What PPR actually does

PPR isn't a new rendering mode so much as a compiler-assisted merge of two existing ones. At build time, Next.js prerenders your route as far as it can. The moment it hits a dynamic API — cookies(), headers(), an uncached fetch, searchParams — it stops, drops a Suspense boundary, and serialises a hole into the static HTML. At request time, the server streams the dynamic piece into that hole.

The result is a single response: the static shell flushes immediately (great for LCP and TTFB), and the dynamic part arrives over the same connection without a client round-trip.

The important mental model: the Suspense boundary is the contract. Everything above it is static and cached at the edge. Everything inside it is dynamic, per-request, and runs on every hit.

Why this is different from loading.tsx

loading.tsx gives you a route-level fallback while the server renders. PPR gives you a prerendered fallback baked into HTML at build time. With loading.tsx, the first byte still waits on your server. With PPR, the first byte is already on the CDN.

Enabling it without lighting your build on fire

As of Next.js 15, PPR is still flagged. Turn it on incrementally — not globally — unless you enjoy debugging surprises across 200 routes.

// next.config.ts
import type { NextConfig } from 'next';

const config: NextConfig = {
  experimental: {
    ppr: 'incremental',
  },
};

export default config;

Then opt in per route:

// app/dashboard/page.tsx
export const experimental_ppr = true;

export default function DashboardPage() {
  return <Dashboard />;
}

Incremental mode is the only sane way to adopt this. You want to flip it on for one route, run your CI, look at the build output, and confirm the prerender boundary is where you expected.

The Suspense boundary is load-bearing

Here's the gotcha that catches every team on their first PPR route: if you call a dynamic API outside a Suspense boundary, the entire route becomes dynamic. PPR doesn't fall back gracefully — it gives up on prerendering that route altogether.

This fails to prerender:

// app/account/page.tsx
import { cookies } from 'next/headers';

export const experimental_ppr = true;

export default async function AccountPage() {
  const session = (await cookies()).get('session');
  return (
    <main>
      <h1>Account</h1>
      <UserPanel session={session?.value} />
    </main>
  );
}

The cookies() call at the top poisons the whole tree. Move the dynamic work into a child component wrapped in Suspense:

// app/account/page.tsx
import { Suspense } from 'react';
import { UserPanel } from './user-panel';
import { AccountSkeleton } from './skeleton';

export const experimental_ppr = true;

export default function AccountPage() {
  return (
    <main>
      <h1>Account</h1>
      <Suspense fallback={<AccountSkeleton />}>
        <UserPanel />
      </Suspense>
    </main>
  );
}

// app/account/user-panel.tsx
import { cookies } from 'next/headers';

export async function UserPanel() {
  const session = (await cookies()).get('session');
  const user = await getUser(session?.value);
  return <section>Hi, {user.name}</section>;
}

Now the <main> and <h1> are in the prerendered shell. The AccountSkeleton is baked into the HTML. The UserPanel streams in.

Layouts can prerender too — and they should

A frequent mistake: putting auth checks in the root layout. That makes every route in your app dynamic, PPR or not. Push auth into the segments that actually need it, or into a middleware that sets headers your dynamic boundaries can read.

Where PPR pays off

For anything resembling a product UI with a stable chrome and personalised content, the wins are real. Logged-in dashboards, marketplace listings, product detail pages with user-specific pricing — anywhere you have a clear visual split between "the page" and "your slice of the page."

In our experience, the biggest measurable wins show up in LCP and TTFB on routes that previously had to be fully dynamic because of a single cookie read. Moving that cookie read behind a Suspense boundary often pulls LCP down significantly, because the browser can paint the hero shell before the dynamic data has even been fetched.

INP is a separate conversation. PPR doesn't help you there — that's a hydration and client-work problem. If your dashboard janks on interaction, PPR won't save you; you need to look at client component boundaries and React 19's transition APIs.

Where it bites

Cache invalidation gets weird

The static shell is, well, static. It's generated at build time and lives on the CDN. If your shell contains anything that changes — a navigation item gated by a feature flag, a promo banner, a translated string — you need revalidatePath or a deployment to update it. Teams that treat the shell as "basically dynamic" get burned.

A practical rule: if it can change without a deploy, it belongs inside a Suspense boundary. Even if it feels static.

searchParams is dynamic, always

Reading searchParams in a page makes it dynamic. If you have a filterable list page and you want PPR, the filter UI needs to live in a Suspense boundary that reads searchParams, while the page shell stays static. This is annoying but workable.

export const experimental_ppr = true;

export default function ProductsPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  return (
    <>
      <ProductsHeader />
      <Suspense fallback={<ResultsSkeleton />}>
        <Results searchParams={searchParams} />
      </Suspense>
    </>
  );
}

Middleware can silently kill PPR

If your middleware rewrites or sets cookies on a PPR route, you can end up with cases where the static shell is bypassed. Audit which routes your middleware actually touches, and where possible move per-request logic into the dynamic boundary instead.

Skeletons are now part of your design system

Because the Suspense fallback is prerendered into HTML, it's the first thing every visitor sees. A lazy <div>Loading...</div> becomes the LCP element on half your pages. Treat skeletons as a first-class design system component: same spacing, same border radii, same dark-mode tokens. If you're building or refactoring a system to support this kind of streaming UI, this is the kind of thing we help teams sort out.

A sensible route structure for PPR

After a few projects, a pattern that holds up:

  • Page file: server component, no dynamic APIs, defines the static shell and Suspense boundaries.
  • Dynamic children: server components that call cookies(), headers(), or uncached fetches. One per logical chunk, each with its own Suspense parent.
  • Skeletons: co-located with each dynamic child, exported as named components.
  • Client components: leaves only. Hydration boundaries should be as small as possible.
app/
  dashboard/
    page.tsx              // static shell + Suspense boundaries
    metrics.tsx           // dynamic, reads cookies
    metrics-skeleton.tsx  // prerendered fallback
    activity.tsx          // dynamic, uncached fetch
    activity-skeleton.tsx
    chart.client.tsx      // client leaf inside metrics

This keeps the prerender boundary obvious in the file tree, which matters when someone six months from now adds a cookies() call to the wrong file and tanks your LCP.

Should you use it?

If you're on Next.js 15+, using the App Router, and you have at least one route that's currently fully dynamic because of a single cookie or header read — yes, try it on that route. The ROI is high and the blast radius is small.

If you're on the Pages Router, or your app is mostly client-rendered, PPR isn't your bottleneck. Fix that first.

If your route is genuinely dynamic end-to-end — every byte depends on the user — PPR gives you nothing. Don't force it.

Where we'd start

Pick your highest-traffic authenticated route. Open the build output and find where it says ƒ (Dynamic). Identify the single dynamic call that's forcing it. Wrap it in Suspense, add experimental_ppr = true, and redeploy. Measure LCP before and after on a real device, not just Lighthouse. If the number moves, expand outward. If it doesn't, you've learned something about where your real bottleneck lives — and that's worth the afternoon either way.

#Next.js#React#Performance#App Router#Edge

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project