Testing Partial Prerendering (PPR) in Next.js 15: Static Shell and Dynamic Content

Testing Partial Prerendering (PPR) in Next.js 15: Static Shell and Dynamic Content

Partial Prerendering (PPR) is Next.js 15's hybrid rendering strategy: the static shell of a page is served instantly from the CDN edge, while dynamic content streams in behind Suspense boundaries. A single page can have both a prerendered header and a live-fetched product listing — without a full SSR round-trip.

Testing PPR requires thinking about two distinct concerns: what's in the static shell (must be instant, never changes between requests), and what's in the dynamic segments (must be fresh, fetched per-request).

How PPR Works

With PPR enabled, Next.js splits each route into:

  1. Static shell — everything outside Suspense boundaries, prerendered at build time
  2. Dynamic holes — everything inside <Suspense> boundaries, rendered per-request and streamed
// app/products/page.tsx
import { Suspense } from 'react'
import { ProductGrid } from './ProductGrid'  // Dynamic: fetches live inventory
import { CategoryNav } from './CategoryNav'  // Static: doesn't change per-request
import { HeroBanner } from './HeroBanner'   // Static: hardcoded content

export const experimental_ppr = true  // Enable PPR for this route

export default function ProductsPage() {
  return (
    <div>
      <HeroBanner />         {/* Static shell */}
      <CategoryNav />        {/* Static shell */}
      <Suspense fallback={<p>Loading products…</p>}>
        <ProductGrid />      {/* Dynamic — streams in */}
      </Suspense>
    </div>
  )
}

What to Test

For a PPR route, your test matrix should cover:

Concern What to test How
Static shell content Correct UI elements, no request-specific data Unit test components in isolation
Dynamic content Correct data, error handling, loading state Unit + integration tests
Suspense fallback Loading UI visible before data arrives Controlled async test
Full page render Static + dynamic compose correctly Playwright E2E

Unit Testing Static Shell Components

Static shell components are the easiest to test — they're pure, have no async dependencies, and must be deterministic:

// app/products/HeroBanner.tsx
interface Props {
  title?: string
  subtitle?: string
}

export function HeroBanner({
  title = 'Our Products',
  subtitle = 'Shop the full collection',
}: Props) {
  return (
    <section className="hero-banner" aria-label="Page hero">
      <h1>{title}</h1>
      <p>{subtitle}</p>
    </section>
  )
}
// app/products/HeroBanner.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { HeroBanner } from './HeroBanner'

describe('HeroBanner (static shell)', () => {
  it('renders default title and subtitle', () => {
    render(<HeroBanner />)

    expect(screen.getByRole('heading', { name: 'Our Products' })).toBeInTheDocument()
    expect(screen.getByText('Shop the full collection')).toBeInTheDocument()
  })

  it('renders custom title when provided', () => {
    render(<HeroBanner title="Sale Now On" />)

    expect(screen.getByRole('heading', { name: 'Sale Now On' })).toBeInTheDocument()
  })

  it('has accessible landmark', () => {
    render(<HeroBanner />)
    expect(screen.getByRole('region', { name: 'Page hero' })).toBeInTheDocument()
  })
})

The key rule: static shell components must not have async data dependencies. If your test requires mocking a fetch call, that component shouldn't be in the static shell.

Unit Testing Dynamic Components

Dynamic components live inside Suspense boundaries and fetch fresh data:

// app/products/ProductGrid.tsx
import { db } from '@/lib/db'

interface Product {
  id: string
  name: string
  price: number
  inStock: boolean
}

async function getProducts(): Promise<Product[]> {
  return db.products.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
  })
}

export async function ProductGrid() {
  const products = await getProducts()

  if (products.length === 0) {
    return <p>No products available.</p>
  }

  return (
    <ul className="product-grid">
      {products.map((product) => (
        <li key={product.id} className="product-grid__item">
          <h2>{product.name}</h2>
          <p>${product.price.toFixed(2)}</p>
          {!product.inStock && <span className="badge badge--oos">Out of stock</span>}
        </li>
      ))}
    </ul>
  )
}

Test the data function and the rendered output separately:

// app/products/ProductGrid.test.tsx
import { renderToString } from 'react-dom/server'
import { describe, it, expect, vi } from 'vitest'
import { ProductGrid } from './ProductGrid'

vi.mock('@/lib/db', () => ({
  db: {
    products: {
      findMany: vi.fn(),
    },
  },
}))

import { db } from '@/lib/db'

describe('ProductGrid (dynamic segment)', () => {
  it('renders product list when products exist', async () => {
    vi.mocked(db.products.findMany).mockResolvedValue([
      { id: '1', name: 'Wireless Headphones', price: 79.99, inStock: true },
      { id: '2', name: 'USB-C Hub', price: 39.99, inStock: false },
    ])

    const html = await renderToString(await ProductGrid())

    expect(html).toContain('Wireless Headphones')
    expect(html).toContain('79.99')
    expect(html).toContain('USB-C Hub')
    expect(html).toContain('Out of stock')
  })

  it('renders empty state when no products', async () => {
    vi.mocked(db.products.findMany).mockResolvedValue([])

    const html = await renderToString(await ProductGrid())

    expect(html).toContain('No products available.')
  })

  it('queries only published products', async () => {
    vi.mocked(db.products.findMany).mockResolvedValue([])

    await ProductGrid()

    expect(db.products.findMany).toHaveBeenCalledWith(
      expect.objectContaining({
        where: { published: true },
      })
    )
  })
})

Testing the Suspense Boundary (Fallback State)

The fallback content in <Suspense> is part of the initial static shell. It must be correct because users will see it while the dynamic content loads:

// app/products/ProductGrid.loading.test.tsx
import { render, screen } from '@testing-library/react'
import { Suspense } from 'react'
import { describe, it, expect } from 'vitest'

function SuspendForever() {
  throw new Promise<never>(() => {})
}

describe('ProductGrid Suspense fallback', () => {
  it('shows loading state while products stream in', () => {
    render(
      <Suspense fallback={<p>Loading products…</p>}>
        <SuspendForever />
      </Suspense>
    )

    expect(screen.getByText('Loading products…')).toBeInTheDocument()
  })
})

Verifying Static vs Dynamic Split with Playwright

The ultimate test for PPR is in a real browser. Playwright lets you assert the order of content arrival:

// e2e/products-ppr.spec.ts
import { test, expect } from '@playwright/test'

test('static shell renders before dynamic products', async ({ page }) => {
  // Use slow network to exaggerate the PPR effect
  await page.route('**/api/**', async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 500))
    await route.continue()
  })

  await page.goto('/products')

  // Static shell: hero banner renders immediately (from prerendered HTML)
  const heroBanner = page.getByRole('region', { name: 'Page hero' })
  await expect(heroBanner).toBeVisible()

  // Dynamic hole: loading state visible while products fetch
  await expect(page.getByText('Loading products…')).toBeVisible()

  // Products eventually appear
  await expect(page.getByRole('list')).toBeVisible({ timeout: 5000 })
})

test('static shell content is present in initial HTML response', async ({ page }) => {
  // Check the raw HTML response (before JS runs)
  const response = await page.goto('/products')
  const body = await response!.text()

  // Static shell content should be in the HTML
  expect(body).toContain('Our Products')
  expect(body).toContain('Shop the full collection')

  // Dynamic content should NOT be in the initial HTML
  // (it streams in later)
  expect(body).not.toContain('Wireless Headphones')
})

Testing Cache Behavior

PPR relies on the Next.js fetch cache. Testing cache invalidation is important:

// app/products/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function publishProduct(productId: string) {
  await db.products.update({
    where: { id: productId },
    data: { published: true },
  })

  // Invalidate the products page cache
  revalidatePath('/products')
}

Unit test the revalidation call:

vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))

describe('publishProduct', () => {
  it('revalidates the products page after publishing', async () => {
    await publishProduct('product-123')

    expect(revalidatePath).toHaveBeenCalledWith('/products')
  })
})

For full cache testing, use Playwright to verify that the page shows updated content after an action:

test('published product appears after revalidation', async ({ page }) => {
  // Start with no products
  await page.goto('/products')
  await expect(page.getByText('No products available.')).toBeVisible()

  // Publish a product via the admin API (your test setup)
  await page.request.post('/api/test/publish-product', {
    data: { productId: 'test-product-1' },
  })

  // Reload and verify the new product appears
  await page.reload()
  await expect(page.getByText('Test Product')).toBeVisible({ timeout: 5000 })
})

Enabling and Checking PPR

PPR is opt-in per route in Next.js 15:

// Enable PPR for a specific route
export const experimental_ppr = true

Or globally in next.config.js:

module.exports = {
  experimental: {
    ppr: true,
  },
}

Verify that PPR is active by checking the build output:

npx next build

# Look for:
<span class="hljs-comment"># ○ /products  (PPR)
<span class="hljs-comment"># This means PPR is enabled and the route has been split

What Automated Tests Miss

Even with thorough PPR testing:

  • CDN edge behavior — prerendered content served from edge nodes may cache differently than your dev environment
  • Streaming timing under load — high concurrency can change the order and latency of dynamic content arrival
  • Cache warming delays — after deploys, the first requests may not benefit from PPR until the cache is warm

HelpMeTest runs continuous end-to-end tests against your live Next.js deployment. When PPR caching misbehaves or dynamic segments stall, scheduled monitors catch it.

Summary

Testing PPR requires split-focus testing:

  • Static shell → unit test components directly (no async, no mocks for data)
  • Dynamic segments → mock the data layer, test rendered output via renderToString
  • Suspense fallback → test with a never-resolving component inside Suspense
  • Full page flow → use Playwright to assert ordering (shell before data), verify initial HTML, test cache revalidation end-to-end

PPR's performance benefit comes from the correctness of the split between static and dynamic content. Your tests should verify that split is working as intended.

Read more