---
title: Control CDN caching for shared and personalized pages on Vercel
description: Control CDN caching for shared and personalized Next.js pages. Set cache headers, separate per-user content, and verify responses on Vercel.
url: "https://vercel.com/kb/guide/cdn-cache-personalized-pages"
published: 2026-11-02
last_updated: 2026-11-02
authors: Ben Sabic
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

Vercel's CDN can serve shared responses without invoking a Function, reducing latency and compute cost. Caching account data can expose it to other visitors.

In a Next.js 16 App Router project, return `Cache-Control: private, no-store` from personalized Route Handlers and keep CDN-specific caching headers off those routes. Page caching also depends on the rendering mode.

## Decide whether a response is shared or personalized

Public blog posts and product listings can be shared across visitors. Dashboards, carts, and greetings that use a visitor's name are personalized because their content depends on who's asking.

The CDN stores the full response under a cache key built from the request. Suppose a Vercel Function uses Alice's session cookie to render her account page and opts it into CDN caching. Bob could then receive that page when he requests the same URL in the same region.

Before you add a CDN caching directive to a route, check that the exact response would be correct for every visitor. If it wouldn't, keep it out of the CDN cache with `private` or `no-store`, or move the per-user part into a separate request.

## Set cache directives for the CDN and the browser

Vercel Function responses need an explicit caching directive, such as `s-maxage`, to enter the CDN cache. Static assets are cached automatically, and frameworks set policies for static and ISR pages.

The default `Cache-Control` value is `public, max-age=0, must-revalidate`. Browsers may store that response but must validate it before reuse.

Use `s-maxage` for CDN freshness and `max-age` for browser freshness. Unless noted, the table assumes `Cache-Control` is the only cache header.

| Directive                  | Behavior                                                                                                                                                                       |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `s-maxage=N`               | Keeps the CDN response fresh for `N` seconds, from 1 second to 1 year. Browsers ignore this directive.                                                                         |
| `stale-while-revalidate=N` | Lets the CDN serve a response for up to `N` seconds after it becomes stale while refreshing it in the background.                                                              |
| `stale-if-error=N`         | Lets the CDN serve a response for up to `N` seconds after it becomes stale if the origin request fails, such as with a 500, network failure, or DNS error.                     |
| `private`                  | Prevents CDN storage and permits browser storage.                                                                                                                              |
| `no-store`                 | Prevents CDN and browser storage.                                                                                                                                              |
| `max-age=N`                | Sets browser freshness to `N` seconds. In a targeted cache header, it sets CDN freshness. `max-age` alone in `Cache-Control` does not opt Function responses into CDN caching. |

When `Cache-Control` is the only cache header, Vercel removes `s-maxage`, `stale-while-revalidate`, and `stale-if-error` before forwarding the response.

With a targeted header, `s-maxage` stays in `Cache-Control` but browsers ignore it.

These Route Handler excerpts omit application-specific data and authentication helpers. Supply those helpers from your application; shared data must be correct for every request that uses the same cache entry.

The products response stays fresh at the CDN for 1 hour. After it becomes stale, the CDN can serve it for up to 1 day while refreshing it in the background. The stale-if-error allowance also lasts up to 1 day from the end of freshness:

```typescript
export async function GET() {
  const products = await getProducts();

  return Response.json(products, {
    headers: {
      'Cache-Control': [
        'public',
        'max-age=0',
        's-maxage=3600',
        'stale-while-revalidate=86400',
        'stale-if-error=86400',
      ].join(', '),
    },
  });
}
```

The browser receives `public, max-age=0`, so its copy is immediately stale. It checks with the CDN before reusing that copy, and the CDN may return stale content during the windows configured above.

Setting `max-age` to 60 or 120 seconds lets visitors reuse a fresh browser copy without contacting the CDN. This can delay newly deployed content.

The `/api/me` endpoint validates the session and returns only the name used by the account badge. Its application-provided `getUserFromSession()` helper must return `null` for an invalid or expired session. Replace the example auth-module import with your application's implementation:

```typescript
import { cookies } from 'next/headers';
import { getUserFromSession } from '../../lib/auth';

export async function GET() {
  const session = (await cookies()).get('session')?.value;
  const user = session ? await getUserFromSession(session) : null;

  return Response.json(user ? { name: user.name } : null, {
    status: user ? 200 : 401,
    headers: { 'Cache-Control': 'private, no-store' },
  });
}
```

With `private, max-age=0`, the browser may store a copy but treats it as stale immediately. Use `no-store` for sensitive responses that no cache should retain.

## Control the CDN separately from the browser

The three cache headers let you give Vercel's CDN, other CDNs, and browsers different instructions. Vercel's CDN uses the highest-priority header present:

| Header                     | Priority on Vercel's CDN | Who else reads it                  | Sent to the browser?        |
| -------------------------- | ------------------------ | ---------------------------------- | --------------------------- |
| `Vercel-CDN-Cache-Control` | 1 (highest)              | No other cache                     | No                          |
| `CDN-Cache-Control`        | 2                        | Other CDNs and intermediary caches | Yes, but browsers ignore it |
| `Cache-Control`            | 3 (lowest)               | Browsers and other caches          | Yes                         |

Header priority applies wherever you set the header. For example, `CDN-Cache-Control` in `next.config.js` overrides a Vercel Function's `Cache-Control`.

When both places set the same header, the Vercel Function's value takes precedence over `next.config.js` or `vercel.json`.

This shared response stays fresh for 1 hour on Vercel's CDN and 10 seconds in the browser. Other CDNs that honor `CDN-Cache-Control` use the 60-second lifetime:

```typescript
export async function GET() {
  return Response.json(await getCatalog(), {
    headers: {
      'Cache-Control': 'max-age=10',
      'CDN-Cache-Control': 'max-age=60',
      'Vercel-CDN-Cache-Control': 'max-age=3600',
    },
  });
}
```

CDN-specific headers also take precedence over `Cache-Control: private`, including the `private` value Next.js sets on dynamic pages. Caching directives in those headers can therefore make a personalized response cacheable.

Send personalized responses with `Cache-Control: private, no-store` and no CDN-specific cache headers. Check that `next.config.js` doesn't add those headers.

## Understand how cookies, auth headers, and Vary affect caching

Vercel's CDN builds its cache key from the request method, URL, host, deployment URL, and scheme, plus `Accept`, `Accept-Encoding`, and headers named in `Vary`. Cookies sent with a request are excluded, so visitors with different sessions can receive the same cached response.

For Vercel Function responses, these conditions stop CDN storage even when you set a caching lifetime:

| Condition                                                                             | Result                                                                                   |
| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| Request has an `Authorization` header                                                 | Response isn't cached                                                                    |
| Response has a `Set-Cookie` header                                                    | Response isn't cached                                                                    |
| The cache header selected by precedence contains `private`, `no-cache`, or `no-store` | Response isn't cached                                                                    |
| Response has `Vary: *`                                                                | Response isn't cached                                                                    |
| Response `Vary` includes `Cookie`                                                     | Not cached, served with `x-vercel-cache: MISS` and cache reason `vary_key_denied:cookie` |

Sending `Cookie` alone does not exclude a response from the CDN cache. For cookie-based sessions, return `Cache-Control: private, no-store` and keep targeted caching headers off the route.

`Vary` creates a cache entry for each value of the named request headers. Choose a header with a bounded set of values, such as `X-Vercel-IP-Country`.

This country-based response uses `getPromo(country)`, an application helper that must return the same promotion for everyone in that country:

```typescript
import { type NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
  const country = request.headers.get('x-vercel-ip-country') ?? 'unknown';

  return Response.json(await getPromo(country), {
    headers: {
      'Cache-Control': 's-maxage=3600',
      Vary: 'X-Vercel-IP-Country',
    },
  });
}
```

Don't use `Vary: Cookie` to make per-user responses safe to cache. Vercel's CDN doesn't cache those responses at all, because session and analytics cookies give most visitors a distinct value. Set `Vary` on the specific routes that need it rather than globally, so one header doesn't make every route uncacheable.

## Cache pages that mix shared and personalized content

With `cacheComponents: false`, the full route's rendering mode sets these headers:

- Static pages use `s-maxage=31536000`.
  
- Incremental Static Regeneration (ISR) uses the route's revalidation settings.
  
- Dynamic pages use `private, no-cache, no-store, max-age=0, must-revalidate`.
  

Calling `cookies()` or `headers()` in a page or layout makes that route dynamic. Reading them in `app/layout.tsx` affects every page that uses the layout.

Keep rules that add shared cache directives in `next.config.js` or `vercel.json` away from personalized pages, and verify the deployed response headers.

### Fetch the personalized part separately

Keep the shared page and its layouts free of request-time APIs, and make its product data available during prerendering. Then load account data in the browser from `/api/me`, whose handler returns `Cache-Control: private, no-store`.

Create the account badge to fetch account data after it mounts:

```tsx
'use client';

import { useEffect, useState } from 'react';

export function AccountBadge() {
  const [name, setName] = useState<string | null | undefined>(undefined);
  const [failed, setFailed] = useState(false);

  useEffect(() => {
    let active = true;

    fetch('/api/me')
      .then((response) => {
        if (response.status === 401) return null;
        if (!response.ok) throw new Error('Unable to load account');
        return response.json();
      })
      .then((user) => {
        if (active) setName(user?.name ?? null);
      })
      .catch(() => {
        if (active) setFailed(true);
      });

    return () => { active = false; };
  }, []);

  if (failed) return <span>Account unavailable</span>;
  if (name === undefined) {
    return <span className="account-badge-placeholder" aria-busy="true" />;
  }

  return name ? <span>Signed in as {name}</span> : <a href="/login">Sign in</a>;
}
```

Render `AccountBadge` beside your existing `ProductGrid` in the products page. This excerpt assumes your application provides both `ProductGrid` and `getProducts()`.

The helper must work during prerendering. Request-time APIs or explicit fetch options such as `cache: 'no-store'` and `revalidate: 0` make the page dynamic:

```tsx
import { AccountBadge } from '../components/account-badge';

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <main>
      <AccountBadge />
      <ProductGrid products={products} />
    </main>
  );
}
```

When the page is prerendered, the product grid is shared and each browser requests its own account data. Match the placeholder's dimensions to the badge to avoid a layout shift.

### Split shared and personalized content into separate routes

Let pages such as `/account` and `/orders` render dynamically when most of their content is per-user. Shared routes such as `/products` and `/pricing` must avoid request-time APIs in their pages and layouts. Explicit fetch options such as `cache: 'no-store'` or `revalidate: 0` also make those routes dynamic.

Move a layout that reads session cookies into a route group containing only per-user pages, such as `app/(account)/layout.tsx`. Keep that session read out of `app/layout.tsx` so it does not affect shared pages outside the group.

### Use Partial Prerendering with Cache Components

With Cache Components enabled, Vercel serves a cached static shell from the CDN and streams per-request content from your function into the same response.

Place components that read `cookies()` inside `<Suspense>`. Use `use cache` for shared data or components that belong in the shell, and follow the [Partial Prerendering setup](https://vercel.com/docs/partial-prerendering/quickstart) to configure this pattern.

## Check what the CDN cached

Inspect `x-vercel-cache` on your production URL with a `GET` request. Run this command twice without changing the URL:

```bash
curl -sS -D - -o /dev/null \
  https://your-domain.com/api/products \
  | grep -iE '^(HTTP/|x-vercel-cache:|cache-control:)'
```

Once an eligible shared response has populated the regional cache, a repeat request can return headers like these:

```text
cache-control: public, max-age=0
x-vercel-cache: HIT
```
| `x-vercel-cache` value | Meaning                                                                          |
| ---------------------- | -------------------------------------------------------------------------------- |
| `HIT`                  | Served from the CDN cache                                                        |
| `MISS`                 | Not in the cache, so Vercel generated it and stored it if cacheable              |
| `STALE`                | Served a stale copy and refreshed it in the background                           |
| `BYPASS`               | Skipped the cache for this request, for example in Draft Mode                    |
| `PRERENDER`            | Served from static storage, such as a page prerendered at build time             |
| `REVALIDATED`          | Revalidated or regenerated in the foreground before Vercel returned the response |

Check the private `/api/me` endpoint with repeated `GET` requests for two valid sessions, then without a session. Use `curl -i` with each session cookie to inspect both headers and the response body. Each signed-in response must contain only that user's data; the unauthenticated response must contain no account data.

Expect `MISS` for this private handler. `BYPASS` also means the cache was skipped, but a few misses alone do not prove the policy is safe.

Verify `Cache-Control: private, no-store` and remove targeted caching headers.

If this endpoint returns `HIT` or `STALE`, deploy the corrected policy and [delete any previously cached personalized response](https://vercel.com/docs/caching/cdn-cache/purge#understanding-cache-purging) before checking again. Invalidation can still serve stale data.

Apply this test to the private API response. Partial Prerendering can serve a cached shell while generating personalized content for each request.

Browser developer tools often send `Pragma: no-cache`, which makes the CDN revalidate stale responses synchronously. Use `curl` or a private window with devtools closed when you measure cache behavior.

For more context than a single header, open **Logs** in your project dashboard and expand a request. The **Cache** section shows the status and a reason, such as `vary_key_denied:cookie` when a response varies on `Cookie`.

To see caching across all traffic, open **Observability** and select **CDN Requests**, which shows how many requests to each route were served from the cache.

## Next steps

- Set up [Partial Prerendering](https://vercel.com/docs/partial-prerendering/quickstart) for a shared shell and per-request content.
  
- Check the [Cache Components configuration](https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents) for your Next.js app.
  
- Use [Runtime Cache](https://vercel.com/docs/caching/runtime-cache) for data fetched inside Vercel Functions, including data used by personalized routes.
  
- Check [cache directives and header precedence](https://vercel.com/docs/caching/cache-control-headers).
  
- Diagnose requests with the [cache status and reason reference](https://vercel.com/docs/caching/cache-status).

## Related Documentation

- [Vercel CDN](/docs/cdn)

## More Vercel CDN guides

- [Optimize Runtime Cache on Vercel](/kb/guide/optimize-runtime-cache-reads-on-vercel): Diagnose rising Runtime Cache usage, trace reads to tags and code paths, and reduce avoidable reads while checking write cost and origin traffic.
- [How to revalidate Sitecore pages on publish with Next.js](/kb/guide/update-sitecore-content-manually): Learn how to use a Sitecore Experience Edge webhook and Next.js revalidatePath to refresh cached Sitecore Content SDK pages after each publish.
- [Caching audits: Five antipatterns that cost you performance and money](/kb/guide/caching-antipatterns): Five caching antipatterns from hundreds of Vercel technical audits: write amplification, deploy-wiped caches, spinner shells, and how to diagnose each.