Next.jsPerformancePPRReact Server ComponentsArchitecture

Partial Pre-rendering (PPR): The Secret to 0ms LCP in Modern React Apps

S
Sebastian Markbåge (Inspired)
Featured Guide 20 min read

Static or Dynamic? Why not both?

For a decade, we've had to choose. Static Site Generation (SSG) gave us speed but stale data. Server Side Rendering (SSR) gave us fresh data but slow TTFB.
Partial Pre-rendering (PPR) breaks this binary. It is the quantum superposition of web architecture.

The Dilemma: You're building an E-commerce product page. The Navbar, Footer, and Product Description are static—they never change. The "Add to Cart" button and "Related Products" are dynamic—they depend on user cookies and inventory.

Traditionally, if *one* part is dynamic, the *whole* page has to be SSR. You lose that instant "Edge" delivery. PPR allows the static shell to be served instantly from the edge (0ms LCP), while the dynamic holes are streamed in parallel.

02. What is Partial Pre-rendering?

PPR is a compiler optimization that treats your React tree as a static shell with dynamic "holes".

It uses React Suspense boundaries to delimit these zones. Anything inside a Suspense boundary that reads dynamic data (cookies, headers, non-cached fetch) is considered dynamic. Everything else is pre-rendered at build time.

// app/product/[id]/page.js

// 🟢 STATIC SHELL (Pre-rendered)
export default function Page({ params }) {
  return (
    <main>
      <Header />
      <ProductDetails id={params.id} />
      
      {/* 🔴 DYNAMIC HOLE (Streamed) */}
      <Suspense fallback={<CartSkeleton />}>
        <UserCart />
      </Suspense>
      
      {/* 🔴 DYNAMIC HOLE (Streamed) */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <PersonalizedReviews />
      </Suspense>
      
      <Footer />
    </main>
  );
}

03. The Request-Response Lifecycle

1. The Immediate Response

The Edge CDN instantly returns the pre-computed HTML shell. The user sees the header, footer, and loading skeletons immediately. TTFB is effectively ~10-20ms.

2. The Dynamic Stream

The server (or lambda) starts executing the dynamic parts. As they complete, chunks of HTML/Script are streamed into the existing response, replacing the skeletons.

06. Share the Knowledge

⚡️

Did You Know?

PPR is being adopted by major e-commerce giants because reducing LCP (Largest Contentful Paint) by 100ms has been shown to increase conversion rates by up to 1%.

#NextJS #PPR #WebVitals

Interactive Playground

import React, { useState, useEffect, Suspense } from 'react';

// 🧪 Simulation of PPR
// This component demonstrates how the UI shell appears instantly
// while "dynamic" parts load in.

function SlowComponent({ delay, name }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    const t = setTimeout(() => {
      setData(`Dynamic Data for ${name}`);
    }, delay);
    return () => clearTimeout(t);
  }, []);

  if (!data) throw new Promise(() => {}); // Never settles in this dummy, handled by parent logic or just simulate loading
  return <div className="p-4 bg-green-500/20 text-green-400 border border-green-500 rounded animate-in fade-in">{data}</div>;
}

// Actual simulation for the demo UI
function SimulatedDynamicPart({ delay, name }) {
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
     setTimeout(() => setLoading(false), delay);
  }, []);

  if (loading) return (
    <div className="h-24 w-full bg-gray-800 rounded animate-pulse flex items-center justify-center">
        <span className="text-gray-600 text-sm">Loading {name} (Stream)...</span>
    </div>
  );

  return (
    <div className="h-24 w-full bg-gradient-to-r from-green-900/20 to-green-800/20 border border-green-700 rounded p-4 flex items-center gap-4 animate-in slide-in-from-bottom-2">
        <div className="w-12 h-12 bg-green-500 rounded-full flex items-center justify-center text-black font-bold"></div>
        <div>
            <div className="font-bold text-green-400">Loaded: {name}</div>
            <div className="text-xs text-green-500/60">Streamed in {delay}ms</div>
        </div>
    </div>
  );
}

export default function PPRDemo() {
  const [mounted, setMounted] = useState(false);
  
  // Re-trigger demo
  const reload = () => {
    setMounted(false);
    setTimeout(() => setMounted(true), 100);
  };

  useEffect(() => {
      setMounted(true);
  }, []);

  return (
    <div className="p-8 max-w-2xl mx-auto bg-black border border-gray-800 rounded-xl min-h-[600px] font-sans text-white">
      <div className="flex justify-between items-center mb-8 border-b border-gray-800 pb-4">
         <div>
             <h1 className="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-orange-400 to-red-400">PPR Simulator</h1>
             <p className="text-gray-500 text-sm">Static Shell vs Dynamic Stream</p>
         </div>
         <button onClick={reload} className="px-4 py-2 bg-white text-black font-bold rounded hover:bg-gray-200">
             Replay Request
         </button>
      </div>

      {/* STATIC SHELL (Instant) */}
      <nav className="flex gap-6 mb-8 text-gray-400 text-sm font-medium">
          <span className="text-white border-b-2 border-orange-500 pb-1">Products</span>
          <span>Solutions</span>
          <span>Pricing</span>
          <span>Docs</span>
      </nav>

      <div className="grid grid-cols-3 gap-4 mb-8">
          <div className="col-span-2 space-y-4">
              <div className="h-64 bg-gray-900 rounded-lg p-6 border border-gray-800">
                  <div className="text-xs font-bold text-orange-500 mb-2">STATIC CONTENT</div>
                  <h2 className="text-3xl font-bold mb-4">The Future is Partial.</h2>
                  <p className="text-gray-400 leading-relaxed">
                      This entire block is pre-rendered at build time. It is served from the Edge. The user sees this instantly, achieving 0ms LCP perception.
                  </p>
              </div>

              {/* DYNAMIC HOLE 1 */}
              <div className="border border-dashed border-gray-700 p-4 rounded-lg relative">
                  <div className="absolute -top-3 left-4 bg-black px-2 text-xs text-blue-400">Dynamic Hole (Reviews)</div>
                  {mounted && <SimulatedDynamicPart delay={1200} name="User Reviews" />}
              </div>
          </div>

          <div className="space-y-4">
              {/* DYNAMIC HOLE 2 */}
              <div className="border border-dashed border-gray-700 p-4 rounded-lg relative h-full">
                  <div className="absolute -top-3 left-4 bg-black px-2 text-xs text-purple-400">Dynamic Hole (Cart)</div>
                  <div className="space-y-4 pt-2">
                       {mounted && <SimulatedDynamicPart delay={600} name="Cart Items" />}
                       {mounted && <SimulatedDynamicPart delay={800} name="Recs" />}
                  </div>
              </div>
          </div>
      </div>
      
      <footer className="text-center text-gray-600 text-xs mt-12 border-t border-gray-900 pt-8">
          © 2026 Asio Inc. All rights reserved. (Static Footer)
      </footer>
    </div>
  );
}