Dynamic Headers & Cookies in Next.js 16: A Complete Guide
Learn how to implement Partial Prerendering (PPR) in Next.js 16 for optimal performance and static rendering.

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
I was building an e-commerce dashboard last month when I hit a frustrating wall. The product listing page loaded instantly for everyone—beautiful, cached, perfect. But the moment I needed to show a personalized banner at the top (pulling the user's session from cookies), the entire page suddenly became dynamic. Every visitor now had to wait for server processing instead of getting the pre-rendered version. The static benefits disappeared across the whole route, not just the small section that actually needed the cookie data.
That's where Next.js 16's Partial Prerendering (PPR) comes in. Instead of choosing between "fully static" or "fully dynamic," you can now keep the page statically prerendered by default and explicitly defer only the sections that need request-time data like cookies() or headers(). The rest of the page stays lightning-fast.
In this guide, I'll walk you through exactly how I implemented this pattern and how you can use it in your own projects.
Step 1: Enable PPR in Your Configuration
First, you need to tell Next.js that you want to use Partial Prerendering. This happens in your next.config.ts file:
// next.config.ts
import type { NextConfig } from 'next'
const config: NextConfig = {
experimental: {
ppr: 'incremental',
},
}
export default config
The incremental setting is important here. It means PPR won't automatically apply to every route in your app. Instead, you explicitly opt-in on a per-route basis. This gives you fine-grained control—you only enable PPR where it actually benefits you, avoiding unnecessary complexity in routes that don't need it.
The alternative is ppr: true, which would enable PPR globally, but incremental is safer for most projects since you're being intentional about which routes use this feature.
Step 2: Export the PPR Experimental Flag from Your Route
Now, pick a specific route where you want to enable this behavior. I'll use the products page as an example:
// app/products/page.tsx
export const experimental_ppr = true
export default function ProductsPage() {
return <ProductsListing />
}
By exporting experimental_ppr = true, you're telling Next.js, "I want this route to be statically prerendered by default. Only the parts I explicitly mark as dynamic should wait for request-time data."
Without this export, the route either behaves fully static or fully dynamic depending on what code you use. With it, you're bridging the gap—you get a static shell that can be served instantly, and dynamic content streams in as needed.
Step 3: Build Your Static Components Normally
The beauty of PPR is that the majority of your page stays completely normal—no special handling required. Here's the product listing component, which has no dependencies on request-time data:
// app/products/ProductsListing.tsx
export default async function ProductsListing() {
// This fetch is cached, so it's safe for static generation
const products = await getAllProducts()
return (
<section>
<h1>Products</h1>
<div className="grid">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</section>
)
}
This component stays completely static during build time. The getAllProducts() call is cached (either through Next.js's built-in caching, ISR, or your own cache layer), so Next.js prerendered the entire component tree and serves it instantly to every visitor.
The key principle: as long as you're not calling cookies(), headers(), or unstable_noStore() inside a component, that component is safe to prerender. PPR only comes into play when you need request-time values.
Step 4: Create Your Dynamic Component That Uses Cookies or Headers
Now for the part that would normally make your whole page dynamic. This is where you read the user's session cookie to personalize the experience:
// app/products/UserBanner.tsx
import { cookies } from 'next/headers'
export async function UserBanner() {
// This is request-time data—it changes per visitor
const cookieStore = await cookies()
const session = cookieStore.get('session')?.value
if (!session) {
return <div className="banner">Welcome, guest. <a href="/login">Sign in</a></div>
}
return (
<div className="banner">
Welcome back! <a href="/account">View your account</a>
</div>
)
}
In a traditional Next.js app, rendering this component anywhere in the tree would force the entire page to be dynamic. The page wouldn't be prerendered at build time—instead, the server would process every single request from scratch.
But with PPR and Suspense boundaries, this component can stay isolated. It will only execute when a request comes in, while the rest of the page (the ProductsListing) is already prerendered and ready to serve instantly.
Step 5: Combine Them with Suspense
Here's the final piece: wrap the dynamic component in a Suspense boundary inside your main page:
// app/products/page.tsx
import { Suspense } from 'react'
import ProductsListing from './ProductsListing'
import { UserBanner } from './UserBanner'
export const experimental_ppr = true
export default function ProductsPage() {
return (
<>
<ProductsListing /> {/* Prerendered at build time */}
<Suspense fallback={<div className="banner-skeleton">Loading your info…</div>}>
<UserBanner /> {/* Deferred and streamed when request comes in */}
</Suspense>
</>
)
}
This is where the magic happens. When Next.js builds this route with experimental_ppr = true:
- It prerendered the
ProductsListingcomponent and everything above the Suspense boundary - It creates a static shell of the page (all HTML outside the Suspense boundary)
- When a visitor requests the page, the server sends the static shell immediately while
UserBannerprocesses in parallel - The user sees the products right away and the personalized banner streams in moments later
- Meanwhile, fully static visitors (who don't need real-time personalization) get the static shell cached at the edge
The fallback inside Suspense is what users see while UserBanner is processing. It should be visually similar to what will be rendered—a skeleton loader works well here.
The Real-World Impact
Before PPR, you faced an uncomfortable choice: keep the page fully static and show generic content to everyone, or use cookies/headers and accept that the entire page becomes dynamic. That meant no prerendering, no edge caching, slower initial load times for every visitor.
Now with PPR, you're getting the best of both worlds. Your static content (the product listing) is prerendered and cached globally. Your dynamic content (the personalized banner) only processes when needed. The user gets fast page loads, and you get the personalization you need.
The key takeaway is that PPR isn't about making your entire page dynamic or static—it's about being explicit with Suspense boundaries about which parts are which. The parts above the boundary are static and fast. The parts inside the boundary are dynamic and personalized.
Let me know in the comments if you've run into this challenge before, or if you have questions about implementing PPR in your own projects. And if you want to dive deeper into handling nested layouts or preventing child routes from inheriting dynamic rendering, let me know—I can write a follow-up guide.
Thanks, Matija