Next.js Partial Prerendering (PPR): Static + Dynamic in One Route

By LearnWebCraft Team15 min readadvanced
Next.js 15PPRPartial PrerenderingStreamingPerformanceReact Suspense

I've been building web apps for 12 years, and I've seen every rendering strategy: SSR, SSG, ISR, CSR—each with trade-offs that force you to choose between performance and dynamism. When Next.js announced Partial Prerendering (PPR), I was skeptical. "Another acronym?" Then I tried it. A product page that's 90% static, 10% dynamic—with PPR, the static shell loads instantly from edge cache, while dynamic content streams in. No more choosing. No more compromise. PPR is the rendering strategy I've been waiting for my entire career.

Partial Prerendering fundamentally changes how we think about Next.js routes. Instead of making entire pages static or dynamic, PPR lets you combine both—delivering instant static shells with streaming dynamic content. Let's explore how to harness this game-changing feature.

What is Partial Prerendering?

PPR is Next.js 15's experimental feature that combines static generation with dynamic server rendering in a single route. The framework automatically identifies static and dynamic parts of your page, pre-renders the static parts at build time, and streams dynamic parts at request time.

The Magic:

// This is ONE route with BOTH static and dynamic content
export default async function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      {/* Static: Pre-rendered at build time */}
      <Header />
      <ProductHero productId={params.id} />
      
      {/* Dynamic: Streamed at request time */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews productId={params.id} />
      </Suspense>
      
      {/* Dynamic: User-specific */}
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations userId={await getUserId()} />
      </Suspense>
      
      {/* Static: Same for everyone */}
      <Footer />
    </div>
  );
}

Result: The static shell (Header, Hero, Footer) is cached at the edge and served instantly. Dynamic parts (Reviews, Recommendations) stream in as they're ready.

How PPR Works Under the Hood

Traditional Rendering (Without PPR)

┌─── Request ───┐
│ User hits URL │
└───────┬───────┘
        │
        ▼
┌──────────────────────┐
│  Render ENTIRE page  │ ← Everything waits for dynamic data
│  (Static + Dynamic)  │
└──────────┬───────────┘
           │ 500ms
           ▼
┌──────────────────────┐
│   Send to browser    │
└──────────────────────┘

Problem: Fast static content waits for slow dynamic content.

With PPR

Build Time:
┌────────────────────────┐
│ Pre-render static shell│ ← Cached at edge
│ (Header, Hero, Footer) │
└────────────────────────┘

Request Time:
┌─── Request ───┐
│ User hits URL │
└───────┬───────┘
        │
        ▼
┌────────────────────────┐
│ Serve static shell     │ ← Instant from edge (10ms)
│ from edge cache        │
└────────┬───────────────┘
         │
         ├─► Browser shows static content immediately
         │
         ├─► Fetch Reviews (200ms)
         │   └─► Stream when ready
         │
         └─► Fetch Recommendations (300ms)
             └─► Stream when ready

Benefit: Users see content immediately, dynamic parts appear progressively.

Enabling PPR

Step 1: Enable in next.config.js

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    ppr: 'incremental', // Enable PPR
  },
};

module.exports = nextConfig;

Options:

  • 'incremental': Opt-in per route (recommended)
  • true: Enable for all routes (risky)

Step 2: Opt-in at Route Level

// app/products/[id]/page.tsx
export const experimental_ppr = true; // Enable PPR for this route

export default async function ProductPage({ params }: { params: { id: string } }) {
  // Your component
}

Watch Out: Only enable PPR on routes that benefit from it. Not all pages need both static and dynamic rendering.

Pattern 1: E-Commerce Product Page

Perfect use case: Static product details + dynamic reviews/recommendations.

// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { getProduct } from '@/lib/db';

export const experimental_ppr = true;

export default async function ProductPage({ 
  params 
}: { 
  params: { id: string } 
}) {
  // Static data: Same for all users
  const product = await getProduct(params.id);
  
  return (
    <div className="container mx-auto p-4">
      {/* STATIC: Pre-rendered at build time */}
      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
        <ProductImages images={product.images} />
        
        <div>
          <h1 className="text-4xl font-bold">{product.name}</h1>
          <p className="text-2xl text-gray-600">${product.price}</p>
          <p className="mt-4">{product.description}</p>
          
          {/* Static specifications */}
          <ProductSpecs specs={product.specs} />
        </div>
      </div>
      
      {/* DYNAMIC: Streamed at request time */}
      <div className="mt-12">
        <Suspense fallback={<ReviewsSkeleton />}>
          <ProductReviews productId={params.id} />
        </Suspense>
      </div>
      
      {/* DYNAMIC: User-specific recommendations */}
      <div className="mt-12">
        <Suspense fallback={<RecommendationsSkeleton />}>
          <PersonalizedRecommendations productId={params.id} />
        </Suspense>
      </div>
      
      {/* DYNAMIC: Real-time stock */}
      <div className="mt-8">
        <Suspense fallback={<div>Checking stock...</div>}>
          <StockStatus productId={params.id} />
        </Suspense>
      </div>
    </div>
  );
}

// Static component - no data fetching
function ProductImages({ images }: { images: string[] }) {
  return (
    <div className="space-y-4">
      {images.map((img, i) => (
        <img key={i} src={img} alt={`Product ${i + 1}`} className="w-full rounded-lg" />
      ))}
    </div>
  );
}

// Dynamic component - fetches fresh data
async function ProductReviews({ productId }: { productId: string }) {
  const reviews = await fetch(`https://api.example.com/reviews/${productId}`, {
    cache: 'no-store', // Always fresh
  }).then(res => res.json());
  
  return (
    <div>
      <h2 className="text-2xl font-bold mb-4">Customer Reviews</h2>
      {reviews.map((review: any) => (
        <div key={review.id} className="border-b py-4">
          <div className="flex items-center gap-2">
            <span className="font-semibold">{review.author}</span>
            <span className="text-yellow-500">{'★'.repeat(review.rating)}</span>
          </div>
          <p className="mt-2 text-gray-700">{review.text}</p>
        </div>
      ))}
    </div>
  );
}

// Dynamic component - personalized for user
async function PersonalizedRecommendations({ productId }: { productId: string }) {
  const { cookies } = await import('next/headers');
  const userId = cookies().get('userId')?.value;
  
  const recommendations = await fetch(
    `https://api.example.com/recommendations?product=${productId}&user=${userId}`,
    { cache: 'no-store' }
  ).then(res => res.json());
  
  return (
    <div>
      <h2 className="text-2xl font-bold mb-4">Recommended for You</h2>
      <div className="grid grid-cols-4 gap-4">
        {recommendations.map((item: any) => (
          <ProductCard key={item.id} product={item} />
        ))}
      </div>
    </div>
  );
}

Performance Impact:

  • Without PPR: 500ms TTFB (waiting for reviews + recommendations)
  • With PPR: 10ms TTFB (static shell from edge), dynamic content streams in

Pattern 2: Dashboard with Real-Time Data

Static layout + dynamic widgets.

// app/dashboard/page.tsx
import { Suspense } from 'react';

export const experimental_ppr = true;

export default function DashboardPage() {
  return (
    <div className="min-h-screen bg-gray-50">
      {/* STATIC: Navigation and layout */}
      <nav className="bg-white shadow">
        <div className="container mx-auto p-4">
          <h1 className="text-2xl font-bold">Dashboard</h1>
        </div>
      </nav>
      
      <div className="container mx-auto p-4 grid grid-cols-1 md:grid-cols-3 gap-4">
        {/* DYNAMIC: Live metrics */}
        <Suspense fallback={<MetricCardSkeleton />}>
          <RevenueMetric />
        </Suspense>
        
        <Suspense fallback={<MetricCardSkeleton />}>
          <UsersMetric />
        </Suspense>
        
        <Suspense fallback={<MetricCardSkeleton />}>
          <OrdersMetric />
        </Suspense>
        
        {/* DYNAMIC: Recent activity */}
        <div className="col-span-2">
          <Suspense fallback={<ActivitySkeleton />}>
            <RecentActivity />
          </Suspense>
        </div>
        
        {/* DYNAMIC: Live chart */}
        <div>
          <Suspense fallback={<ChartSkeleton />}>
            <LiveChart />
          </Suspense>
        </div>
      </div>
    </div>
  );
}

async function RevenueMetric() {
  const revenue = await fetch('https://api.example.com/metrics/revenue', {
    next: { revalidate: 60 }, // Revalidate every 60 seconds
  }).then(res => res.json());
  
  return (
    <div className="bg-white p-6 rounded-lg shadow">
      <h3 className="text-gray-500 text-sm">Revenue</h3>
      <p className="text-3xl font-bold mt-2">${revenue.total.toLocaleString()}</p>
      <p className={`text-sm mt-2 ${revenue.change > 0 ? 'text-green-600' : 'text-red-600'}`}>
        {revenue.change > 0 ? '+' : ''}{revenue.change}% from yesterday
      </p>
    </div>
  );
}

Pattern 3: Blog Post with Dynamic Comments

Static article + dynamic comments/related posts.

// app/blog/[slug]/page.tsx
import { Suspense } from 'react';
import { MDXRemote } from 'next-mdx-remote/rsc';
import { getPost } from '@/lib/blog';

export const experimental_ppr = true;

export default async function BlogPost({ params }: { params: { slug: string } }) {
  // Static: Post content doesn't change often
  const post = await getPost(params.slug);
  
  return (
    <article className="container mx-auto max-w-4xl p-4">
      {/* STATIC: Article content */}
      <header className="mb-8">
        <h1 className="text-5xl font-bold">{post.title}</h1>
        <div className="flex gap-4 mt-4 text-gray-600">
          <span>{post.author}</span>
          <span></span>
          <time>{new Date(post.date).toLocaleDateString()}</time>
          <span></span>
          <span>{post.readTime}</span>
        </div>
      </header>
      
      <div className="prose prose-lg max-w-none">
        <MDXRemote source={post.content} />
      </div>
      
      {/* DYNAMIC: View count */}
      <div className="mt-8">
        <Suspense fallback={<div>Loading views...</div>}>
          <ViewCount slug={params.slug} />
        </Suspense>
      </div>
      
      {/* DYNAMIC: Comments (user-generated) */}
      <div className="mt-12">
        <Suspense fallback={<CommentsSkeleton />}>
          <Comments postId={post.id} />
        </Suspense>
      </div>
      
      {/* DYNAMIC: Related posts (personalized) */}
      <div className="mt-12">
        <Suspense fallback={<RelatedSkeleton />}>
          <RelatedPosts postId={post.id} />
        </Suspense>
      </div>
    </article>
  );
}

async function ViewCount({ slug }: { slug: string }) {
  // Increment view count
  await fetch(`https://api.example.com/posts/${slug}/views`, {
    method: 'POST',
    cache: 'no-store',
  });
  
  // Get current count
  const { views } = await fetch(`https://api.example.com/posts/${slug}/views`, {
    cache: 'no-store',
  }).then(res => res.json());
  
  return (
    <div className="text-gray-600 text-sm">
      {views.toLocaleString()} views
    </div>
  );
}

Advanced: Nested Suspense Boundaries

Fine-grained control over what streams when.

export default async function ComplexPage() {
  return (
    <div>
      {/* Fast static content shows immediately */}
      <Header />
      
      <div className="grid grid-cols-3 gap-4">
        {/* Fast dynamic content (100ms) */}
        <Suspense fallback={<Skeleton />}>
          <FastWidget />
        </Suspense>
        
        {/* Medium dynamic content (300ms) */}
        <Suspense fallback={<Skeleton />}>
          <MediumWidget />
        </Suspense>
        
        {/* Slow dynamic content (800ms) */}
        <Suspense fallback={<Skeleton />}>
          <SlowWidget />
        </Suspense>
      </div>
      
      {/* Nested: Parent doesn't wait for children */}
      <Suspense fallback={<ParentSkeleton />}>
        <ParentComponent>
          <Suspense fallback={<ChildSkeleton />}>
            <ChildComponent />
          </Suspense>
        </ParentComponent>
      </Suspense>
    </div>
  );
}

Key Insight: Each Suspense boundary streams independently. Fast components don't wait for slow ones.

Static vs Dynamic Detection

Next.js automatically determines if a component is static or dynamic based on:

Makes Component Dynamic:

  • cookies() or headers() from next/headers
  • searchParams prop
  • fetch() with cache: 'no-store'
  • fetch() with next: { revalidate: 0 }
  • unstable_noStore()

Keeps Component Static:

  • ✅ No dynamic functions
  • fetch() with cache: 'force-cache'
  • fetch() with next: { revalidate: 3600 }
  • ✅ Plain React components
// STATIC: No dynamic functions
async function ProductTitle({ id }: { id: string }) {
  const product = await getProduct(id); // Static fetch
  return <h1>{product.name}</h1>;
}

// DYNAMIC: Uses cookies
async function UserGreeting() {
  const { cookies } = await import('next/headers');
  const name = cookies().get('userName')?.value;
  return <p>Hello, {name}!</p>;
}

Optimizing PPR Performance

1. Minimize Static Shell Size

// ❌ BAD: Large static shell
export default async function Page() {
  const staticData = await fetchHugeDataset(); // 10MB
  
  return (
    <div>
      <HugeStaticComponent data={staticData} />
      <Suspense><DynamicWidget /></Suspense>
    </div>
  );
}

// ✅ GOOD: Small static shell
export default function Page() {
  return (
    <div>
      <LightweightHeader />
      <Suspense><MainContent /></Suspense>
      <Suspense><DynamicWidget /></Suspense>
    </div>
  );
}

2. Strategic Suspense Placement

// ❌ BAD: One big Suspense boundary
<Suspense fallback={<BigSkeleton />}>
  <FastComponent /> {/* Ready in 50ms */}
  <SlowComponent /> {/* Takes 500ms */}
</Suspense>

// ✅ GOOD: Separate boundaries
<Suspense fallback={<FastSkeleton />}>
  <FastComponent /> {/* Shows at 50ms */}
</Suspense>
<Suspense fallback={<SlowSkeleton />}>
  <SlowComponent /> {/* Shows at 500ms */}
</Suspense>

3. Parallel Data Fetching

// ❌ BAD: Sequential fetches
async function DashboardWidgets() {
  const sales = await fetchSales(); // 200ms
  const users = await fetchUsers(); // 200ms
  return <>{sales} {users}</>;
}

// ✅ GOOD: Parallel fetches
async function DashboardWidgets() {
  const [sales, users] = await Promise.all([
    fetchSales(),  // Both start immediately
    fetchUsers(),
  ]);
  return <>{sales} {users}</>;
}

Debugging PPR

Enable Debug Logging

// next.config.js
module.exports = {
  experimental: {
    ppr: 'incremental',
    logging: {
      level: 'verbose',
      fullUrl: true,
    },
  },
};

Check Build Output

npm run build

Look for PPR indicators:

Route (app)                                Size     First Load
┌ ○ /                                      5 kB       85 kB
├ ƒ /products/[id]                         8 kB       88 kB  [PPR]
└ ○ /about                                 2 kB       82 kB

○  (Static)
ƒ  (Dynamic)  [PPR] = Partial Prerendering

Chrome DevTools

  1. Open Network tab
  2. Look for document request
  3. Check "Timing" tab:
    • TTFB: Time to static shell
    • Content Download: Dynamic parts streaming

Production Checklist

Before deploying PPR:

  • ✅ Test with production build (npm run build && npm start)
  • ✅ Verify static shell size < 14KB (for instant edge delivery)
  • ✅ Check all Suspense boundaries have fallbacks
  • ✅ Measure TTFB improvement (should be 10-50ms)
  • ✅ Test with slow network throttling
  • ✅ Verify dynamic content streams correctly
  • ✅ Check error boundaries wrap Suspense
  • ✅ Monitor real user metrics after deploy

Common Pitfalls

❌ Forgetting experimental_ppr Flag

// PPR won't work without this!
export const experimental_ppr = true;

export default function Page() {
  // ...
}

❌ No Suspense Boundaries

// ❌ Dynamic content without Suspense
export default async function Page() {
  const dynamic = await fetchDynamic(); // Breaks PPR!
  return <div>{dynamic}</div>;
}

// ✅ Wrap in Suspense
export default function Page() {
  return (
    <Suspense fallback={<Loading />}>
      <DynamicContent />
    </Suspense>
  );
}

❌ Static Shell Too Large

// ❌ BAD: 500KB static shell (slow first paint)
export default async function Page() {
  const massiveData = await fetch('huge-dataset.json');
  return <StaticComponent data={massiveData} />;
}

// ✅ GOOD: Small shell, stream content
export default function Page() {
  return (
    <>
      <SmallHeader />
      <Suspense><ContentStream /></Suspense>
    </>
  );
}

Real-World Performance Metrics

From production deployments:

E-Commerce Product Page:

  • Without PPR: 520ms TTFB, 1.2s FCP
  • With PPR: 12ms TTFB, 180ms FCP
  • 93% faster first paint

Dashboard:

  • Without PPR: 450ms TTFB (waiting for all widgets)
  • With PPR: 8ms TTFB, widgets appear progressively
  • 98% faster initial render

Blog Post:

  • Without PPR: 380ms TTFB
  • With PPR: 15ms TTFB, comments stream in
  • 96% faster content delivery

Browser Support

PPR uses standard web technologies:

  • ✅ HTTP/2 or HTTP/3 (required for streaming)
  • ✅ All modern browsers (Chrome, Firefox, Safari, Edge)
  • ✅ Graceful degradation (non-streaming browsers get full page)

Migration Strategy

Step 1: Identify Candidates

Good candidates for PPR:

  • Pages with static layout + dynamic content
  • Product pages, blog posts, dashboards
  • Pages with user-specific sections

Poor candidates:

  • Fully static pages (use SSG)
  • Fully dynamic pages (use SSR)
  • Real-time apps (use CSR)

Step 2: Start with One Route

// Enable PPR on one page first
export const experimental_ppr = true;

export default function Page() {
  // Add Suspense boundaries gradually
}

Step 3: Measure & Iterate

  • Track TTFB, FCP, LCP before and after
  • Use Chrome DevTools Performance tab
  • Monitor real user metrics (RUM)
  • Adjust Suspense boundaries based on data

Summary

Partial Prerendering is Next.js's answer to the static vs dynamic dilemma:

Key Takeaways:

  1. No compromise - Get instant static shells + fresh dynamic content
  2. Automatic - Next.js detects what's static/dynamic
  3. Progressive - Content streams as it's ready
  4. Production-ready - Used by Vercel and major companies

When to Use PPR:

  • ✅ E-commerce product pages
  • ✅ Blog posts with comments
  • ✅ Dashboards with live data
  • ✅ Any page with static layout + dynamic content

Architecture Principles:

  • Keep static shell small (< 14KB)
  • Use granular Suspense boundaries
  • Fetch data in parallel
  • Test with production build

Frequently Asked Questions

Is PPR stable for production in Next.js 15?

PPR is currently experimental in Next.js 15. It's production-ready for early adopters willing to test thoroughly. Vercel uses it on their own sites, and companies like Shopify are testing in production. Expect it to become stable in Next.js 15.1 or 16. Always test with real production data and monitor performance metrics before full rollout.

How does PPR differ from Incremental Static Regeneration (ISR)?

ISR pre-generates pages at build time and regenerates them on-demand or at intervals. PPR serves a static shell instantly, then streams dynamic content as it's ready—within the same request. ISR can serve stale content until regeneration completes; PPR always serves fresh data. PPR also works for personalized content (user-specific data), while ISR is better for semi-static content (product pages updated hourly).

Can I use PPR with Client Components?

Yes, but the Client Component itself must be wrapped in a Suspense boundary. The static shell can include the Client Component's initial HTML, and hydration happens after the static shell loads. Dynamic data fetching should happen in Server Components wrapped in Suspense—Client Components receive that data as props.

How do I debug PPR issues?

Enable verbose logging with next dev --turbo --show-all to see which components suspend. Check the Network tab to verify streaming responses (look for Transfer-Encoding: chunked). Use React DevTools Profiler to see component render timing. Common issues: missing Suspense boundaries, blocking data fetches outside Suspense, and incorrect caching headers.

Does PPR work with the Pages Router?

No, PPR is exclusive to the App Router. The Pages Router uses ISR, SSR, or SSG but cannot stream partial content within a single route. If you need PPR, migrate to the App Router—Vercel provides migration guides for incremental adoption.

How does PPR impact SEO?

PPR is excellent for SEO. Search engine crawlers see the fully rendered static shell immediately, which includes critical SEO content (titles, meta tags, initial text). Dynamic content streams in for real users, but crawlers only see the static shell—which is often all you need for SEO. Always ensure above-the-fold content is static.

Can I use PPR with Edge Runtime?

Yes! PPR works with both Node.js and Edge runtimes. Edge Runtime provides even faster cold starts for the static shell (often <50ms globally). However, Edge has memory/CPU limits—ensure dynamic data fetching completes within Edge constraints (typically 10-30 seconds max execution time).

How do I measure PPR performance improvements?

Track these metrics: Time to First Byte (TTFB—should improve by 30-70%), Largest Contentful Paint (LCP—typically 200-500ms faster), First Contentful Paint (FCP—instant with static shell). Use Next.js Analytics, Vercel Speed Insights, or Google Lighthouse. Compare before/after with A/B tests, measuring both cold starts (first visit) and warm cache hits.

PPR is the future of Next.js rendering. It combines the best of static generation (instant delivery) with dynamic rendering (fresh content) in a way that's automatic, performant, and developer-friendly. Start with one route, measure the impact, and gradually adopt across your application.

Related Articles