Guides

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

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.

Start with Acme Shop's cache 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 that distinction down before adding a header or a tag.

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.

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.

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. Those responses do not meet Vercel's CDN cacheability criteria.

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}]}

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

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 because it is broader than this catalog change and can increase origin load.

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.

Authoritative references

Make caching a controlled application behavior

Talk to Optimi about cache boundaries, CDN validation, and origin protection for high-traffic Next.js applications.

Discuss cache architecture