---
title: "Next.js Caching on Vercel: Cache Boundaries and Safe Defaults"
description: "A practical guide to Next.js caching on Vercel: cache Acme Shop public catalog and database reads safely with cache boundaries and safe cache defaults, while keeping personalized responses out of shared caches."
canonical_url: https://optimi.com/en/guides/nextjs-vercel-cache-model
md_url: https://optimi.com/en/guides/nextjs-vercel-cache-model.md
last_updated: 2026-07-15
---

# Next.js Caching on Vercel: Safe Defaults for Public Content

Cache one public Acme Shop catalog route deliberately, verify each layer, and keep every personalized response outside shared storage.

Caching on Vercel is not one switch. A Next.js application can cache a server-side fetch, prerender route output, and let Vercel's CDN cache a complete HTTP response. These are separate layers with separate invalidation behavior. This guide uses Acme Shop's public catalog; it does not cache its account, cart, checkout, or authenticated API responses.

At the scale a managed edge orchestration layer runs, a single misapplied cache directive is not a local bug — it is a policy that quietly runs for every visitor, in every region, until someone notices. That is why cache boundaries get decided deliberately, per route, before a header or a tag is written.

> **Shared caches require public output**
>
> A shared cache can return one stored response to many visitors. Never apply a shared
>   policy to output that depends on a cookie, authorization header, session, entitlement,
>   cart, or another visitor's state. Keep it dynamic and private instead.

## Outcome and prerequisites

**Outcome:** Acme Shop's public `/shop` listing uses a five-minute Next.js data-cache policy, while a deliberately public JSON endpoint has a short Vercel CDN policy. You can prove the public route is cacheable and the account route is not.

**Prerequisites:** A Next.js 16 App Router project that is not using Cache Components, a linked Vercel preview deployment, a public upstream catalog, and a harmless visible revision such as `catalogRevision: "2026-07-14T10:00Z"`. Use preview before production.

## Next.js caching on Vercel begins with a boundary

The catalog is identical for all visitors and may be up to five minutes old. An account summary is not identical, even when two visitors request the same URL. Write these cache boundaries down, route by route, before adding a header or a tag — "can this response be shared?" is a different question from "is this data slow to fetch?", and only the first one decides whether a shared cache is safe.

**Figure 1. Acme Shop's cache layers and safety boundary**

1. Public visitor — Requests the public catalog representation.
2. Vercel CDN — Can cache the complete public GET /api/public-catalog response.
3. Next.js Data Cache — Persists the server-side catalog fetch with tag acme-shop:catalog.
4. Acme Shop catalog API — Remains the authoritative public product source.

*A signed-in visitor takes /account through a no-store fetch to the account API; it never enters a shared cache.*

The public response cache and the Next.js data cache can both reduce origin work, but a `HIT` at one layer is not proof of a hit at the other. Treat the output's privacy contract as the first cache key.

**Public catalog and private account requests take different cache paths**

![A sequence diagram showing a visitor requesting the public catalog through the Vercel CDN, a Next.js route, and the tagged data cache before the catalog API is called on a data-cache miss. A signed-in account request instead goes directly to a no-store account fetch and returns a visitor-specific response.](/diagrams/nextjs-vercel-cache-model/public-and-private-request-paths.svg)

*A public response cache and a server-side data cache are separate; account data bypasses both shared paths.*

## Cache the public catalog fetch explicitly

Next.js 16 does not cache `fetch` requests by default in this model. Make Acme Shop's intended persistence, freshness, and invalidation target explicit. `force-cache` permits persistent storage; `revalidate` limits its lifetime; the tag is reserved for narrow on-demand invalidation.

```ts
// lib/acme-shop/catalog.ts
export type Product = { slug: string; name: string; priceCents: number }

export async function getPublicCatalog(): Promise<Product[]> {
  const response = await fetch("https://catalog.acme-shop.example/v1/products", {
    cache: "force-cache",
    next: { revalidate: 300, tags: ["acme-shop:catalog"] },
  })

  if (!response.ok) throw new Error("Public catalog is unavailable")
  return response.json()
}
```

```tsx
// app/shop/page.tsx
import { getPublicCatalog } from "@/lib/acme-shop/catalog"

export default async function ShopPage() {
  const products = await getPublicCatalog()
  return <ul>{products.map((product) => <li key={product.slug}>{product.name}</li>)}</ul>
}
```

`revalidate: 300` is a maximum cache lifetime, not a guarantee that every region warms at the same moment. The smallest revalidation value used by a route can also lower that route's revalidation frequency.

Do not assume the absence of `cache: "force-cache"` means "always live." Without an explicit option, the same "uncached" default fetch re-runs on every request once a Request-time API — `cookies()`, `headers()`, or reading `searchParams` — makes the route dynamic, but on a route where none is reachable, it runs only once, at `next build`, and the result is reused indefinitely rather than refreshed on a schedule. Acme Shop's explicit `revalidate: 300` sidesteps that ambiguity: the catalog fetch refreshes on a known cadence regardless of whether `/shop` stays static later.

## Set safe cache defaults for non-fetch reads

Not every read is a `fetch`. Acme Shop's storefront also queries a database directly for the low-stock badge on `/shop`. [`unstable_cache`](https://nextjs.org/docs/app/api-reference/functions/unstable_cache) gives that query the same tag-based, time-bounded lifecycle as the catalog fetch, so both paths share one set of safe cache defaults instead of the query running on every request unbounded.

```ts
// lib/acme-shop/inventory.ts
import { unstable_cache } from "next/cache"
import { db } from "@/lib/acme-shop/db"

export const getLowStockBadges = unstable_cache(
  async () => db.query.products.findMany({ columns: { slug: true, stockCount: true } }),
  ["acme-shop:low-stock-badges"],
  { revalidate: 300, tags: ["acme-shop:catalog"] }
)
```

Reusing the `acme-shop:catalog` tag means one trusted publishing event invalidates the fetch-based product list and the database-backed badge together; see the [revalidation guide](/en/guides/nextjs-vercel-revalidation) for that event. Give `unstable_cache` an explicit key array — its cache key otherwise derives from the function's arguments, and the same query called with a different filter silently becomes a separate entry with its own clock.

Separately, identical `GET` `fetch` calls with the same URL and options made during a single render pass are automatically deduplicated — calling the same catalog fetch from two components costs one request, not two. That memoization resets on the next request, is unrelated to `revalidate`, and does not apply inside Route Handlers; do not mistake it for a persistence guarantee.

## Keep the private account path non-cacheable

Do not try to make a session token part of a shared cache key. Acme Shop fetches account data for the current visitor on every request.

```ts
// lib/acme-shop/account.ts
export async function getAccount(accessToken: string) {
  const response = await fetch("https://accounts.acme-shop.example/v1/me", {
    cache: "no-store",
    headers: { Authorization: `Bearer ${accessToken}` },
  })

  if (!response.ok) throw new Error("Account is unavailable")
  return response.json()
}
```

Reading `cookies()` or `headers()` does not make a shared response safe. Ensure the fetch policy, route behavior, and HTTP response headers all match the private-data requirement.

## Cache one complete public response at the CDN

The data cache does not automatically make an entire HTTP response CDN-cacheable. For Acme Shop's public catalog API only, return a complete representation with a Vercel-specific TTL and a CDN tag. The browser receives the short browser policy; Vercel consumes `Vercel-CDN-Cache-Control` and removes `Vercel-Cache-Tag` before sending the response onward.

```ts
// app/api/public-catalog/route.ts
import { getPublicCatalog } from "@/lib/acme-shop/catalog"

export async function GET() {
  const products = await getPublicCatalog()

  return Response.json(
    { catalogRevision: "2026-07-14T10:00Z", products },
    {
      headers: {
        "Cache-Control": "public, max-age=0, must-revalidate",
        "Vercel-CDN-Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
        "Vercel-Cache-Tag": "acme-shop:catalog-response",
      },
    }
  )
}
```

Do not add these headers when the request carries `Authorization`, the response sets a cookie, output differs by visitor, or the body contains sensitive data. Vercel's cacheability criteria are narrower than "add a header": `GET`/`HEAD` only, no `Range` or `Authorization` request header, status `200`, `404`, `410`, `301`, `302`, `307`, or `308`, body under 10MB (20MB streaming), and no `Set-Cookie`, `private`, `no-cache`, `no-store`, or `Vary: *`. A response failing any one check is not cached at the CDN regardless of the TTL you set.

If the response also needs a policy for another CDN or WAF alongside Vercel, use `CDN-Cache-Control` — not `Vercel-CDN-Cache-Control` — since other providers honor it while Vercel's own header is consumed at Vercel's proxy and never leaves it.

> **Multi-provider caching needs one shared source of truth**
>
> Each provider in that path reports its own hit rate and TTL for the same URL, and none alone shows what a visitor actually received. A managed orchestration layer such as MYO reconciles Vercel's `x-vercel-cache` state with every other provider under one request, so a decision made here never quietly conflicts with a policy set elsewhere in the chain.

**Representative output - public endpoint after a warm request. Header names and cache status are representative; confirm the body marker rather than relying on the header alone.**

```
HTTP/2 200
content-type: application/json
cache-control: public, max-age=0, must-revalidate
x-vercel-cache: HIT

{"catalogRevision":"2026-07-14T10:00Z","products":[{"slug":"solar-pack","name":"Solar Pack","priceCents":12900}]}
```

For resilience, `stale-if-error` can ride alongside `s-maxage`: `stale-if-error=3600` keeps serving the last known-good catalog for up to an hour if the upstream API starts erroring. Vercel caps these directives at one year; caching stays best-effort per region, so a rarely requested route can be evicted before its TTL ends.

## Validate public, private, and failure behavior

Deploy the reviewed change to a preview URL. For protected previews, `vercel curl` automatically handles deployment-protection bypass; it is currently a beta CLI command. For a public preview, ordinary `curl` is sufficient.

```bash
vercel curl /api/public-catalog --deployment https://acme-shop-preview.example
vercel curl /api/public-catalog --deployment https://acme-shop-preview.example
vercel httpstat /api/public-catalog --deployment https://acme-shop-preview.example
```

| Validation | Expected evidence | Stop if |
| --- | --- | --- |
| Positive public route | Two `GET /api/public-catalog` responses have the expected public revision; the second may report `HIT` or `STALE`. | The body contains an account field, a cookie, or a visitor-specific value. |
| Negative private route | An authenticated `/account` response is fresh for the signed-in test user and has no shared `s-maxage` policy. | Different test accounts can receive each other's marker or response. |
| Failure behavior | Temporarily use a non-production upstream test that returns `503`; the route reports its expected error and does not serve a fabricated success body. | An upstream failure is silently stored as a successful catalog response. |

Vercel's CDN is regional. A warm result from one location is not proof that every region is warm. Save the URL, time, headers, body revision, and request location with the change record.

## Recover without broad purges

If a public response is wrong, first remove the unsafe cache eligibility or restore the known-good deployment. Then verify the body and a signed-in journey. Only after the response is correct, invalidate the narrow public CDN tag through a reviewed, linked-project session:

```bash
vercel cache invalidate --tag acme-shop:catalog-response
```

`vercel cache invalidate --tag` marks the response stale: the next request still serves it while a fresh copy loads in the background, so the catalog stays available during recovery. `vercel cache dangerously-delete --tag` removes it outright, so the next request serves `MISS` and blocks on origin — reserve that for a response that must never be served again, not as routine invalidation.

For a wrong Next.js data-cache value, use the authenticated application revalidation workflow in the [revalidation guide](/en/guides/nextjs-vercel-revalidation); a CDN invalidation does not repair bad source data or an unsafe route design. Avoid `vercel cache purge` as a first response: it clears the CDN and data cache for the entire project, not just this catalog change, and can increase origin load across every route at once.

## Troubleshooting

| Symptom | Likely cause | Narrow check or recovery |
| --- | --- | --- |
| `x-vercel-cache: MISS` on a dynamic page | That header describes the CDN response, not necessarily the server-side fetch. | Add a harmless catalog revision to the body and inspect application logs before changing TTLs. |
| Browser `Cache-Control` lacks `s-maxage` | Vercel consumes Vercel-only shared directives before forwarding the response. | Inspect `x-vercel-cache` and the body; do not infer the edge policy from the browser header alone. |
| The catalog never becomes cacheable | The response has `Set-Cookie`, `Authorization`, `private`, `no-store`, `Vary: *`, an unsupported method/status, or is too large. | Remove shared eligibility or keep the route dynamic; never work around a privacy restriction. |
| Local development looks stale after an edit | Server Component HMR can retain fetch responses during development. | Navigate or fully reload; validate caching behavior on a deployed preview. |
| A product update is still old | The five-minute fallback has not expired or the publishing event did not revalidate the tag. | Confirm the source record, then invoke the authenticated, allowlisted webhook once. |
| A response with the right headers still never becomes `HIT` | It silently fails a cacheability criterion — `Set-Cookie`, `Range`, a non-cacheable status, or a body over the size limit. | Inspect the full response, not just the intended header, for the disqualifying field. |
| The low-stock badge and the product list disagree after a publish | `unstable_cache` used a different key or tag than the catalog fetch, so one path invalidated and the other did not. | Confirm both share the `acme-shop:catalog` tag and an explicit, stable key array. |

## Related guides

- [Debug Next.js caching on Vercel](/en/guides/nextjs-vercel-cache-debugging)
- [Revalidate public Next.js data on Vercel](/en/guides/nextjs-vercel-revalidation)

## Authoritative references

- [Next.js caching and revalidation](https://nextjs.org/docs/app/guides/caching-without-cache-components)
- [Next.js `fetch` API reference](https://nextjs.org/docs/app/api-reference/functions/fetch)
- [Next.js `unstable_cache` API reference](https://nextjs.org/docs/app/api-reference/functions/unstable_cache)
- [Vercel CDN Cache](https://vercel.com/docs/caching/cdn-cache)
- [Vercel Cache-Control headers](https://vercel.com/docs/caching/cache-control-headers)
- [Vercel CLI: `vercel cache`](https://vercel.com/docs/cli/cache)

[Discuss cache architecture](/en/contact): Turn cache boundaries into a managed edge discipline — Optimi orchestrates Next.js caching on Vercel alongside every other provider in your stack — safe cache defaults, consistent invalidation, and MYO visibility into Performance, Security, and Visibility for every cached response.
