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:
- Static shell — everything outside Suspense boundaries, prerendered at build time
- 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 = trueOr 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 splitWhat 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.