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:
# ○ /products  (PPR)
# 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

Start now free