Next.js Partial Prerendering: Finally, the Best of Static and Dynamic

By amillionmonkeys
#Next.js#Performance#Web Development

PPR in Next.js 15 lets you mix static shells with dynamic content. We rebuilt a client dashboard to test it—load times dropped 60%.

We rebuilt a client's analytics dashboard last month to test Next.js 15's Partial Prerendering (PPR), and the results caught us off guard. First Contentful Paint dropped from 2.1 seconds to 0.8 seconds. Time to Interactive improved by 60%. The user-facing impact was immediate—the dashboard felt instant, even though half the content was still dynamically generated.

PPR has been in the works for a while, but Next.js 15 finally makes it stable enough to ship to production. If you've ever been stuck choosing between a blazing-fast static site and the flexibility of server-side rendering, PPR is the answer you've been waiting for.

What is Partial Prerendering?

Partial Prerendering lets you combine static and dynamic rendering within the same route. The static parts of your page—navigation, layout, anything that doesn't change per user—get prerendered at build time. Dynamic sections—user data, live metrics, personalized content—stream in afterward.

The browser gets the static shell immediately, then hydrates the dynamic bits as they arrive. To the user, the page appears to load instantly.

Here's what makes it different from traditional SSR or SSG:

  • Traditional SSG: Everything is static. Fast, but can't handle user-specific content.
  • Traditional SSR: Everything is dynamic. Flexible, but slower initial load.
  • PPR: Static shell loads first, dynamic content streams in. Fast AND flexible.

The key is that Next.js determines what's static and what's dynamic automatically based on your code. If you wrap something in Suspense with a fallback, that section becomes dynamic. Everything else is static.

Why We Tested PPR on a Real Dashboard

One of our clients runs a web app with a dense analytics dashboard. It shows real-time metrics, user-specific data, recent activity logs, and personalized recommendations. Classic use case for SSR—everything needs to be fresh on every page load.

The problem? The dashboard was slow. Users complained about wait times, especially on slower connections. We'd already optimized the API calls, reduced bundle size, and implemented caching where we could. But rendering everything server-side meant the initial response was always sluggish.

PPR seemed like a perfect fit:

  • Navigation, header, sidebar, and chart containers could be static
  • Actual chart data, user metrics, and activity feeds needed to be dynamic

We decided to rebuild one route as a proof of concept.

The Implementation: Simpler Than Expected

We started with a typical dashboard route that looked like this:

// Before: Everything server-rendered
export default async function DashboardPage() {
  const metrics = await fetchMetrics();
  const activity = await fetchActivity();
  const userData = await fetchUserData();
 
  return (
    <div>
      <Header />
      <Sidebar />
      <MetricsGrid data={metrics} />
      <ActivityFeed data={activity} />
      <UserProfile data={userData} />
    </div>
  );
}

Every request waited for all three API calls to finish before sending anything to the browser. If fetchActivity() took 800ms, the user saw nothing until it completed.

With PPR enabled, we refactored to this:

// After: Static shell + dynamic content with PPR
export const experimental_ppr = true;
 
export default function DashboardPage() {
  return (
    <div>
      <Header />
      <Sidebar />
      <Suspense fallback={<MetricsSkeleton />}>
        <MetricsGrid />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <ActivityFeed />
      </Suspense>
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile />
      </Suspense>
    </div>
  );
}

Each dynamic component moved its data fetching inside:

// Dynamic component with its own data fetching
async function MetricsGrid() {
  const metrics = await fetchMetrics();
  return <div>{/* render metrics */}</div>;
}

That's it. Enable experimental_ppr at the route level, wrap dynamic sections in Suspense, and Next.js handles the rest.

The Results: 60% Faster Load Times

We deployed to a staging environment and ran Lighthouse tests against both versions. Here's what we measured:

Before PPR (SSR only):

  • First Contentful Paint: 2.1s
  • Largest Contentful Paint: 2.8s
  • Time to Interactive: 3.2s
  • Total Blocking Time: 420ms

After PPR:

  • First Contentful Paint: 0.8s (62% improvement)
  • Largest Contentful Paint: 1.2s (57% improvement)
  • Time to Interactive: 1.3s (59% improvement)
  • Total Blocking Time: 180ms (57% improvement)

Real-world impact on a 3G connection was even more dramatic. The static shell rendered almost instantly, giving users something to interact with immediately. Skeleton loaders showed exactly where data would appear, so the page didn't feel broken while loading.

The client noticed the difference before we even mentioned the change. "Did you speed up the dashboard?" was the first thing they said in our next meeting.

How to Enable PPR in Your Next.js App

PPR is still experimental in Next.js 15, so you'll need to opt in. Here's the step-by-step process:

1. Update to Next.js 15

Make sure you're on Next.js 15 or later:

npm install next@latest react@latest react-dom@latest

2. Enable PPR in next.config.js

Add the experimental flag:

// next.config.js
module.exports = {
  experimental: {
    ppr: true,
  },
};

3. Mark Routes for PPR

Enable PPR on specific routes:

// app/dashboard/page.tsx
export const experimental_ppr = true;
 
export default function DashboardPage() {
  // Your component code
}

4. Wrap Dynamic Sections in Suspense

Identify anything that changes per user or per request, and wrap it:

<Suspense fallback={<Skeleton />}>
  <DynamicComponent />
</Suspense>

5. Move Data Fetching Inside Dynamic Components

Instead of fetching at the route level, fetch inside the component that needs the data:

async function DynamicComponent() {
  const data = await fetchData();
  return <div>{data}</div>;
}

That's it. Build and deploy. Next.js will prerender the static parts at build time and stream in the dynamic parts at runtime.

When to Use PPR (and When Not To)

PPR isn't a silver bullet. We've found it works best in specific scenarios:

Good Use Cases:

  • Dashboards: Static layout, dynamic data
  • E-commerce product pages: Static product info, dynamic inventory/pricing
  • Marketing pages with personalization: Static content, dynamic CTAs or user greetings
  • Blogs with comment sections: Static article, dynamic comments
  • Any page with user-specific content mixed with static structure

Not Ideal For:

  • Fully static marketing sites: Just use SSG. No need for PPR complexity.
  • Fully dynamic apps: If everything changes per user, SSR might be simpler.
  • Pages with no clear static/dynamic split: PPR shines when there's a clear boundary.

The biggest win comes when you have a heavy static shell (navigation, layout, sidebars) and lighter dynamic content. That's where the performance gains are most dramatic.

Gotchas We Hit

Not everything went smoothly. Here are the issues we ran into:

1. Overly Aggressive Caching

Initially, we had some dynamic sections that weren't updating on navigation. Turns out, Next.js was caching the dynamic content more aggressively than we expected. We had to add explicit cache headers:

export const revalidate = 0; // Disable caching for this route

2. Skeleton Flashing

On fast connections, the skeleton loaders flashed briefly before content appeared. We fixed this by adding a minimum display time:

<Suspense fallback={<Skeleton minDisplayTime={300} />}>
  <DynamicContent />
</Suspense>

3. Unexpected Static Sections

We assumed certain components were dynamic, but Next.js treated them as static because they didn't actually use any dynamic data. The solution was to be explicit about what should be dynamic by wrapping it in Suspense, even if it didn't need a loading state.

Production-Ready?

We've had this running in production for three weeks now across two client projects. No issues so far. The performance improvements are real, measurable, and consistent across devices.

That said, PPR is still marked as experimental in Next.js 15. The API might change. We're comfortable using it in production, but if you're risk-averse, wait for the stable release. The Next.js team has indicated PPR will be stable soon, possibly in Next.js 15.1 or 16.

If you're curious about the technical details, the Next.js documentation on PPR is excellent. The Next.js 15 release notes also cover the performance implications in depth.

Key Takeaways

  • PPR combines static and dynamic rendering in the same route: Static shell loads first, dynamic content streams in.
  • Implementation is straightforward: Enable the flag, wrap dynamic sections in Suspense, move data fetching inside components.
  • Performance gains are significant: We saw 60% improvement in Time to Interactive on a real dashboard.
  • Best for mixed static/dynamic pages: Dashboards, e-commerce, personalized content.
  • Still experimental: Use with caution, but we've found it stable enough for production.

If you're building anything with a static layout and dynamic content, PPR is worth testing. The performance improvements are too good to ignore.


Planning a Next.js performance optimization? We've now tested PPR across multiple production apps and can help you identify where it makes sense. Get in touch to discuss your project.

For more on Next.js performance, check out our guide on building performant web applications.

T: 07512 944360 | E: [email protected]

© 2025 amillionmonkeys ltd. All rights reserved.