Tech Verse Logo
Enable dark mode
Next.js Partial Prerendering: Static Shells, Dynamic Holes

Next.js Partial Prerendering: Static Shells, Dynamic Holes

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

The End of the Static vs Dynamic Split

For years, Next.js forced a hard choice at the page boundary. You either built static pages with SSG for near-zero Time to First Byte (TTFB), or you opted into dynamic Server-Side Rendering (SSR) to read cookies, headers, or fresh database records. If you wanted both on the same URL, you had to render a static shell and kick off client-side useEffect fetches after hydration. That pattern added extra HTTP round-trips and caused annoying layout shifts when skeleton loaders didn't match perfectly.

Partial Prerendering (PPR) in Next.js 16 changes this paradigm completely. By combining static build-time shells with React 19 Suspense boundaries, PPR streams dynamic HTML chunks down the exact same initial HTTP request. Your edge server returns the pre-rendered shell instantly—often in under 15ms—while the runtime executes your async server components and streams dynamic content as soon as database queries resolve.

How PPR Functions Under the Hood

When you enable PPR, Next.js analyzes your page layout and component tree during the build step. Anything that doesn't read request-time data like cookies(), headers(), or un-cached fetch() requests gets rendered to static HTML and stored on the edge CDN. Where it encounters a React 19 Suspense boundary wrapping dynamic logic, it inserts a hole marker in the static markup.

When a browser requests the page, the CDN serves the static HTML payload immediately. The browser parses the head, loads CSS, and renders the layout before the backend even finishes querying your database. Meanwhile, Next.js leaves the HTTP connection open and streams HTML replacement chunks directly into those Suspense holes as your backend responses complete.

Configuring PPR in Next.js 16

To enable incremental PPR in Next.js 16, update your next.config.ts file to turn on the experimental flag. This lets you opt in page by page rather than flipping your entire application at once.

import type { NextConfig } from 'next';

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

export default nextConfig;

Once enabled globally, you activate PPR on a route segment by exporting experimental_ppr = true in your page or layout file. Here's a typical e-commerce product route where the title, images, and description are static, but stock levels and user-specific pricing are rendered dynamically from a remote API.

import { Suspense } from 'react';
import ProductHeader from '@/components/ProductHeader';
import ProductDescription from '@/components/ProductDescription';
import LiveInventory from '@/components/LiveInventory';
import InventorySkeleton from '@/components/InventorySkeleton';

export const experimental_ppr = true;

export default async function ProductPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  return (
    <main className="max-w-4xl mx-auto p-6">
      <ProductHeader slug={slug} />
      <ProductDescription slug={slug} />
      <Suspense fallback={<InventorySkeleton />}>
        <LiveInventory slug={slug} />
      </Suspense>
    </main>
  );
}

Connecting Next.js 16 to a Laravel 12 Backend

PPR shines when your Next.js frontend sits in front of a backend API like Laravel 12 running on PHP 8.3. Imagine a high-traffic product page. The static assets and text content don't change often, but live stock count and personalized discount tiers must hit your Laravel API on every hit.

In this architecture, the edge renders the static HTML shell immediately without touching your PHP backend. Only the async Server Component inside the Suspense boundary fires a server-to-server request to your Laravel 12 endpoint.

import { cookies } from 'next/headers';

interface InventoryResponse {
  stock: number;
  price: number;
  currency: string;
}

export default async function LiveInventory({ slug }: { slug: string }) {
  const cookieStore = await cookies();
  const sessionToken = cookieStore.get('session_token')->value;

  const res = await fetch(`https://api.example.com/v1/products/${slug}/live`, {
    headers: {
      'Authorization': `Bearer ${sessionToken ?? ''}`,
      'Accept': 'application/json',
    },
    cache: 'no-store',
  });

  if (!res.ok) {
    return <p className="text-red-500">Unable to load current stock.</p>;
  }

  const data: InventoryResponse = await res.json();

  return (
    <div className="border p-4 rounded-lg bg-slate-50 dark:bg-slate-900">
      <p className="text-xl font-bold">{data.currency} {data.price.toFixed(2)}</p>
      <p className="text-sm text-slate-600 dark:text-slate-400">
        {data.stock > 0 ? `${data.stock} units available` : 'Out of stock'}
      </p>
    </div>
  );
}

Because cookies() is called inside LiveInventory, Next.js automatically isolates that dynamic access inside the Suspense boundary. It doesn't de-opt the surrounding ProductHeader or ProductDescription into dynamic rendering. The static shell streams down instantly, while the backend API call to PHP 8.3 executes in parallel over the wire.

Which Pages Benefit Most

PPR isn't a silver bullet for every route in your application. It delivers the biggest performance gains on pages with a clear mix of universal content and user-specific or high-frequency dynamic data.

E-Commerce Product Detail Pages

Product pages are the primary use case for PPR. The layout, images, specs, and reviews are identical for every customer and change infrequently. However, inventory counts, flash sales, personalized pricing, and cart buttons require user context. PPR lets you serve 90% of the page markup in 10ms from edge caches while streaming the remaining 10% from your API.

SaaS Application Dashboards

Dashboards usually feature persistent sidebar navigation, header layouts, and search inputs that never change between users. Wrapping chart widgets, metric cards, and notification feeds in Suspense boundaries means the user sees the full interface skeleton instantly on page load. They don't stare at a blank white screen while complex aggregation queries run on the backend.

Marketing Pages with Personalized Banners

Landing pages that display personalized welcome messages or location-based offers based on login status can keep all hero images, feature lists, and footers statically prerendered. Only the personalized dynamic banner gets computed at runtime.

Where PPR Fails or Adds Unnecessary Complexity

Don't blindly turn on PPR across every route in your codebase. Some layouts offer zero benefit and just add streaming overhead.

  • Pure admin dashboards with 100% dynamic data: If every single element on the page depends on user permissions, request queries, or real-time webhooks, there is no static shell to extract. Standard SSR is simpler and cleaner here.
  • Simple static content: Blogs, privacy policies, and documentation sites without user interaction don't need PPR. Standard static exports (SSG) or static revalidation remain faster and easier to cache.
  • Routes dominated by heavy database joins: If your dynamic hole takes 3 seconds to resolve because of an unoptimized SQL query in Laravel, streaming the static shell early won't save your user experience. PPR hides network latency; it doesn't fix slow database indexes.

Production Gotchas to Avoid

When deploying Next.js 16 PPR in production environments, several subtle bugs tend to trip up development teams.

Accidental Dynamic Escapes

If you call cookies(), headers(), or access searchParams in a parent layout component outside of a Suspense boundary, Next.js will convert the entire page into dynamic server rendering. Always keep request-time data access tucked inside the deepest dynamic child components wrapped in <Suspense>.

CDN Cache Control Misconfigurations

When running Next.js behind reverse proxies like Nginx or edge CDNs like Fastly or Cloudflare, ensure your caching headers don't accidentally cache the streamed HTTP response headers without supporting chunked transfer encoding. If your proxy buffers the stream waiting for the response to complete before sending it to the client, you completely lose the perception of instant load times.

Wrapping Up

PPR bridges the gap between static performance and dynamic data needs. By using React 19 Suspense boundaries as surgical holes in static HTML shells, Next.js 16 gives you sub-20ms TTFB without compromising on fresh, server-driven data fetching.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    React Context: When to Reach for a Store

    React Context: When to Reach for a Store

    Custom React Hooks: Abstraction vs Indirection

    Custom React Hooks: Abstraction vs Indirection

    Stop Wasting React Performance on memo and useMemo

    Stop Wasting React Performance on memo and useMemo

    Next.js Partial Prerendering: Static Shells, Dynamic Holes

    Next.js Partial Prerendering: Static Shells, Dynamic Holes

    Stop Re-rendering: When to Drop useEffect in React 19

    Stop Re-rendering: When to Drop useEffect in React 19

    Fixing Next.js Hydration Mismatch Errors in React 19

    Fixing Next.js Hydration Mismatch Errors in React 19

    Generating Open Graph Images at the Edge in Next.js 16

    Generating Open Graph Images at the Edge in Next.js 16

    Fix Next.js Third Party Script Performance

    Fix Next.js Third Party Script Performance

    Next.js 16 Testing Strategy: Unit, Component, E2E

    Next.js 16 Testing Strategy: Unit, Component, E2E

    Shrinking Next.js Bundle Size: Analyzer & Barrel Fixes

    Shrinking Next.js Bundle Size: Analyzer & Barrel Fixes