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.

·Matija Žiberna·
Dynamic Headers & Cookies in Next.js 16: A Complete Guide

⚡ 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.

No spam. Unsubscribe anytime.

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:

  1. It prerendered the ProductsListing component and everything above the Suspense boundary
  2. It creates a static shell of the page (all HTML outside the Suspense boundary)
  3. When a visitor requests the page, the server sends the static shell immediately while UserBanner processes in parallel
  4. The user sees the products right away and the personalized banner streams in moments later
  5. 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

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.