Next.js 16 guide

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.

Published
Updated
Reading time
11 min read
On this page

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.

Overview

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 public response cache and a server-side data cache are separate; account data bypasses both shared paths.

Download:PNGSVG

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.

// 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()
}
// 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 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.

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

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

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

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
ValidationExpected evidenceStop if
Positive public routeTwo 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 routeAn 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 behaviorTemporarily 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:

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; 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

SymptomLikely causeNarrow check or recovery
x-vercel-cache: MISS on a dynamic pageThat 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-maxageVercel 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 cacheableThe 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 editServer Component HMR can retain fetch responses during development.Navigate or fully reload; validate caching behavior on a deployed preview.
A product update is still oldThe 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 HITIt 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 publishunstable_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.

Authoritative references

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.

Discuss cache architecture