Blog/Web engineering

Caching a headless commerce front end in Nuxt: route rules, ISR and purge

How to cache a headless commerce front end in Nuxt: route rules, Nitro and CDN keys, ISR limits, tag purge and keeping customer prices out of shared caches.

··11 min read

  • Nuxt
  • Caching
  • ISR
  • Headless commerce
  • Cache invalidation
Cover diagram of a commerce front end where cached pages move through a tag purge loop while customer prices stay outside the cache.

Key takeaways

  • Choose the caching mode per route class, not per site: prerender what never changes, use stale-while-revalidate for catalogue data, and keep cart, checkout and account pages uncached.
  • The name isr does not mean edge caching everywhere; the Nuxt documentation limits the CDN part to platforms that support it, currently Netlify and Vercel, so verify the headers you actually get.
  • Nitro has no tag purge at the origin layer. Tag responses at write time, purge by tag at the CDN from a signed webhook, and keep a short max age as the fallback.
  • Cache the shell of a customer-specific page and fetch prices through an uncached, private endpoint. Never let customer data reach a shared cache, and test that with two accounts.
  • Measure hit ratio from the response headers of each layer, and track the age of the oldest cached entry, because a staleness budget nobody checks is not a budget.

Listen to this article

0:000:00

Caching a headless commerce front end in Nuxt is mostly a question of which pages may be shared. Catalogue pages, category listings and marketing pages can be cached at the edge for minutes, hours or until the next deploy, because every visitor sees the same bytes. A product page for a logged-in B2B customer cannot be handled that way, because the price, the orderable quantity and the add-to-cart state depend on who is asking. The mechanics are not hard. The mistakes come from applying one cache mode to every route.

The working rule is short: choose a mode per route class, keep personalized data out of anything shared, decide in advance how stale each class may be, and decide how a catalogue change reaches the cache. Nuxt route rules give you the mode per path. Nitro decides how the server caches the response. The CDN decides what is shared and how purges work. Each layer has its own defaults, its own header vocabulary and its own limits, and as of October 2026 they differ between Vercel, Cloudflare and a plain Node server. The sections below walk through those layers, then the purge paths, the personalization boundary and the measurement.

Which caching mode fits each route class?

Prerender what never changes, use stale-while-revalidate for catalogue data that tolerates a short delay, and keep cart, checkout and account pages uncached and server-rendered. The table is a starting point. The staleness budgets are business decisions that you should agree per class, not framework defaults.

Route classModeCached whereStaleness budgetPersonalizationInvalidation lever
Marketing and legal pagesprerenderbuild output, CDNuntil next deploynoneredeploy
Category listingsswr or isrserver and CDNagreed per classnonetag purge on catalogue change
Product detail shellswr, client price callHTML shell onlyagreed per classnone in HTMLtag purge plus max age
Customer prices and stockclient island, uncached APInowhere sharednone, read per requestper customernot applicable
Cart, checkout, accountssr, no-storenowhere sharednoneper customernot applicable

The customer-specific data path is not a cache mode at all. It stays out of every shared cache by design, and the page structure should make that boundary visible in code. The diagram shows the request path for a cached product page.

Request path for a cached B2B product pageFour boxes from left to right: the CDN edge serves the cached HTML shell; the client islands hydrate in the browser; the price API is called uncached with a private cache policy; the cart and account pages are server-rendered and never cached. Personalized data never enters the first box.CDN edgecached HTML shellClient islandshydrate in browserPrice APIuncached, privateCart, accountserver-rendered
The request path: cacheable HTML comes from the edge, and anything that depends on the customer goes to an uncached endpoint or to the server.

What do the Nuxt route rules actually do?

Route rules map a path pattern to rendering and caching behaviour. The Nuxt 4 documentation lists redirect, proxy, ssr, cors, headers, swr, isr, prerender and noScripts among the rules, and Nuxt wraps matching routes with the cache handlers that Nitro provides. Nuxt 4.3 added payload extraction for ISR and SWR routes, so the _payload.json files for those routes are generated too and can be cached next to the HTML.

The three modes differ in one important way. swr adds cache headers and caches the response on the server or a reverse proxy for a TTL, then regenerates it in the background after expiry. isr behaves the same way, with one addition: the response can also go into the CDN cache, but the Nuxt documentation limits that to platforms that support it, which it names as Netlify or Vercel. prerender produces the HTML at build time and keeps it until the next deploy.

The consequence is that isr is not a portable name for edge caching. On Vercel it is backed by platform ISR. On a Node server, the same rule gives you stale-while-revalidate at the origin layer, and the CDN story has to be built separately. Write the rule with the platform in mind, and check the response headers in the deployed environment rather than trusting the name.

// nuxt.config.ts (sketch)
export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },
    '/categories/**': { swr: 600 },                  // shared catalogue data, revalidated in the background
    '/products/**': { swr: 300 },                    // shell only; prices come from the client island
    '/cart/**': { ssr: true, headers: { 'cache-control': 'private, no-store' } },
    '/account/**': { ssr: true, headers: { 'cache-control': 'private, no-store' } },
  },
})

How does Nitro cache a response?

Nitro’s cache is built on ocache and takes pluggable storage. Route rules with a cache option are wrapped automatically in defineCachedHandler, and swr: true is a shortcut for cache: { swr: true, maxAge }. Two defaults matter. maxAge defaults to one second, so an entry without an explicit age is nearly worthless, and the guide tells you to set it for anything you want cached. And swr: true without a staleMaxAge writes no storage TTL. The docs call that the ISR pattern, which means cache growth is bounded by the storage backend rather than by time.

Nitro also synthesises the cache headers. A maxAge of 60 produces max-age=60. Adding swr adds s-maxage=60, and adding staleMaxAge: 600 adds stale-while-revalidate=600. Setting sendCacheControl: false keeps the entry on the server without advertising it to browsers and CDNs, which is useful when an origin should cache a response that the shared layers should not store.

Keys are conservative by default. Query parameters and cookies do not reach the handler and do not vary the key, so /page?a=1 and /page?utm=x share one entry. allowQuery and allowCookies opt back in. The docs warn that allowCookies emits Vary: Cookie, which can collapse hit rates in CDNs and shared proxies, and they suggest moving the choice into the URL through allowQuery where possible. set-cookie is removed before caching, so a session cookie cannot leak to another user through the cache, and a response whose own Vary names a header outside the key is returned but not stored. Locale is a key dimension too: each language variant needs its own entry, and the hreflang links must point only at variants that can actually be served, as covered in multilingual Nuxt SEO with hreflang.

The origin cache is only the first layer, and CDN defaults are not about HTML. Cloudflare’s zone cache does not cache HTML or JSON by default; it caches by file extension, and when a response carries no Cache-Control or Expires header it applies default TTLs by status code. Vercel ISR is available on all plans and keeps cached content until it is revalidated or has gone unaccessed for 31 days. Cloudflare Workers Cache is a newer option owned by the Worker. Its key includes the entrypoint, the path and query string, the Worker version and ctx.props, but not the host or the HTTP method. It is driven entirely by Cache-Control and CDN-Cache-Control headers, bypasses automatically on Set-Cookie and Authorization, and stores only GET and HEAD. Variants are stored per exact header value, so Accept-Encoding: gzip, br and br, gzip are separate entries, and Vary: * disables caching.

How do catalogue changes reach the cache?

Tag responses at write time, purge by tag from a webhook, and keep a short max age as the fallback. Nitro has no tag-based purge at the origin layer, so tags live at the CDN. On Cloudflare, a response with a Cache-Tag header can be purged by tag from the dashboard or the API. Tags are case-insensitive and cannot contain spaces. The aggregate header is limited to 16 KB, roughly a thousand unique tags, and an API tag may be up to 1,024 characters. A dashboard purge takes up to 100 tags, and the API limits each request to 100 operations, or 500 on Enterprise. After a tag purge, the next request returns CF-Cache-Status: MISS, or EXPIRED behind Tiered Cache, and the page refills from the origin.

// Purge the CDN cache for every product whose PIM record changed in this batch
const res = await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ tags: changedSkus.map((sku) => `sku:${sku}`) }), // split into chunks of 100
})
if (!res.ok) throw new Error(`purge failed: ${res.status}`)  // fall back to the max age, then alert

The webhook needs the same care as any write path: verify the signature, deduplicate events, and coalesce bursts, because a bulk import can fire thousands of changes in a minute. Purge calls are a rate-limit risk in their own right. Workers Caching purges always use the Free-tier purge rate limits, so a Worker-owned cache that relies on frequent purging should batch them.

Purge propagation after a catalogue changeFive steps from left to right: a PIM update triggers a signed webhook; the webhook purges the matching cache tag; the next request for that product is a miss and refills the cache. Without a purge, the stale-while-revalidate window serves the previous copy until the background refresh completes.PIM updateproduct changedWebhooksigned POSTTag purgesku:1234Next requestMISS, refillStale windowSWR serves old
Purge propagation: a catalogue change removes the tagged entry, the next request refills it, and an untagged entry is served stale until the background refresh finishes.

Tags are the precise lever, but the cheap alternative is a short max age with stale-while-revalidate. RFC 5861 defines stale-while-revalidate as an extension that lets a cache serve a stale response for a set number of seconds while it revalidates in the background. Vercel ISR follows that behaviour, purges HTML and data payloads atomically across all CDN regions, and keeps serving stale content with a 30-second TTL after a failed revalidation. The price is that a customer can briefly see the previous value, so the window length is a business decision, not a technical one.

How do you cache pages that show customer prices?

Cache the shell, not the price. The HTML for a product page is the same for everyone, so it can be shared. The price, the contract discount and the add-to-cart state come from a client island that calls an endpoint which is never shared, such as GET /api/price?sku=…&customer=… with Cache-Control: private. A configured product adds another layer, since the options change the price and the orderable state; headless product configurators for B2B cover that case, and the same boundary applies to it.

When the price API is down, the page needs a defined fallback, and the order of preference matters. Show the list price with a sign-in prompt for the contract price, never a customer price cached from someone else. Do not serve a customer-specific fragment from a shared cache as a fallback. Disable add-to-cart when stock cannot be confirmed, and log the degraded state so it shows up in the hit and error metrics.

This is also a compliance matter in the EU. A shared cache that serves one customer’s page to another is a disclosure of personal data, and the GDPR addresses that through the integrity and confidentiality principle in Article 5(1)(f) and the security of processing in Article 32. Run a leak test with two test accounts, one of them on a different price list, against the deployed environment before launch and after every change to the route rules. In more than ten years of Vue, Nuxt, TypeScript, Node and PHP work, mostly on B2B e-commerce and PIM platforms such as Spryker and Pimcore, this is the question that comes up most often: which routes may be cached when every price depends on the logged-in customer.

Where should you not use stale-while-revalidate?

Never for checkout gates, for availability at the moment of purchase, or for any value that is binding at read time. Stale-while-revalidate is safe for data where a short wrong value is acceptable and corrects itself, such as category listings, descriptions and images. Commerce teams already use these presets, and Hydrogen’s documented caching strategies are a useful reference: a short strategy of 10 seconds, a long strategy of one hour fresh plus a stale window that makes one day in total, and no-store for data that must never be reused. The default sub-request strategy is max-age=1 with a stale window of 86,399 seconds.

RFC 5861 defines the mechanism, not the policy. It was published in 2010 and introduced stale-while-revalidate and stale-if-error as cache extensions, and whether a given cache honours them is up to that cache. The stale window is therefore a promise you make to customers, and it should be written next to the route rule that enforces it. Use this rough split when you draw the lines:

  • Checkout and cart: no-store, always.
  • Stock at purchase time: read live, or accept a checked failure when the order is created.
  • Search with feature flags: key the flag into the URL, or do not cache the result.
  • Catalogue data and content: stale-while-revalidate with a written budget.

How do you measure hits, and what usually goes wrong?

Measure from the headers, not from dashboards you have not set up yet. Vercel returns x-vercel-cache with values such as HIT, MISS, STALE, PRERENDER, REVALIDATED and BYPASS, and its documentation notes that dynamic routes and runtime-cache fetches often show MISS even when they are served from the runtime cache. Cloudflare returns CF-Cache-Status with HIT, MISS, BYPASS, DYNAMIC, EXPIRED, REVALIDATED, UPDATING and STALE, and sets Age on HIT, STALE and UPDATING. Nitro emits its own x-cache header with HIT, STALE, REVALIDATED and MISS. The same page can therefore report two statuses, one from the origin layer and one from the CDN, and a dashboard that reads only one of them will mislead you.

  • Caching authenticated responses. Any response that depends on a cookie or an Authorization header needs an explicit private or no-store policy. Workers Cache bypasses on those headers, but the rule that matters is the one you write for every other cache layer.
  • Vary creep. Adding Vary: Cookie or a broad Accept header splits entries until the hit ratio collapses.
  • Poisoning through uncovered inputs. Anything that changes the response without changing the key, such as a rewrite or override header, either goes into the key or gets stripped before it reaches the cache.
  • Query strings and cookies missing from the key. Nitro leaves both out by default, so a route that depends on a query parameter needs allowQuery, or two URLs will share one entry.
  • Hits that still cost money. On Workers, a cache hit saves CPU time but still counts as a request, so the request bill does not move with the hit ratio.
  • Purge storms. Purge calls are rate limited, and Workers Caching purges use the Free-tier limits, so a bulk import needs batching.

The last footgun bites teams after a successful launch: stale content that nobody checks. Track the age of the oldest cached catalogue entry as well as the hit ratio. A staleness budget only helps if something reports when it is exceeded. The same applies to generated content. I run LLM features on my own Nuxt site and translated it with an LLM pipeline, so once generated text lands in a cached page, a stale translation is a correctness bug, not a cosmetic one.

A rollout checklist

The order matters. A purge path is useless if the responses carry no tags, and a hit ratio is misleading while the personalization boundary is still unclear. Work through the steps in sequence, and treat each one as a gate for the next.

  1. Classify every route into one of the classes in the table, and write the staleness budget next to it.
  2. Set the route rule for each class, and make the platform behaviour of isr explicit.
  3. Add tags to every catalogue response at write time, such as one per SKU and one per category.
  4. Connect the PIM or ERP publish event to a signed webhook that batches purges by tag.
  5. Expose the hit ratio per layer and the age of the oldest cached entry, using the headers described above.
  6. Load-test one purge during peak traffic, and record how long the next requests take to refill the cache.
  7. Run a leak test with two accounts, one on a different price list, against the deployed environment.

If you are building or repairing a commerce front end on Nuxt and want the caching boundary to hold under load, see my Vue and Nuxt work. Rendering speed is a separate question with its own metrics, covered in Nuxt Core Web Vitals.

Sources

  1. Nuxt docs: rendering modes and route rules (v4)
  2. Nuxt blog: Nuxt 4.3 release (ISR and SWR payload extraction)
  3. Nitro docs: caching
  4. Cloudflare docs: default cache behavior
  5. Cloudflare docs: purge by tags
  6. Cloudflare docs: Workers Cache
  7. Cloudflare docs: cache responses (CF-Cache-Status)
  8. Vercel docs: incremental static regeneration
  9. Vercel docs: response headers (x-vercel-cache)
  10. Shopify docs: Hydrogen caching strategies
  11. RFC 5861: HTTP Cache-Control extensions for stale content
  12. Regulation (EU) 2016/679 (GDPR), EUR-Lex

Frequently asked questions

What is the difference between swr and isr in Nuxt route rules?

Both add stale-while-revalidate behaviour on the server or a reverse proxy. The Nuxt documentation says isr can also add the response to the CDN cache on platforms that support this, currently Netlify and Vercel. On a plain Node server, the CDN part does not apply, so check the response headers in your deployment.

How do you purge a cached Nuxt page when a product changes?

Nitro does not provide tag purge at the origin, so tag the response at the CDN with a Cache-Tag header, for example one tag per SKU, and purge by tag from a webhook when the product changes. Keep a short max age with stale-while-revalidate as the fallback when a purge call fails.

Can you cache pages that show B2B customer prices?

Yes, if the cached part is the shared shell and the price comes from a separate endpoint that is never shared. Mark that endpoint private or no-store, and keep customer-specific HTML out of every shared cache, including CDN caches and proxies.

Does Vercel ISR cost money?

As of October 2026, Vercel lists ISR as available on all plans. CDN cache reads and writes are free, while reads and writes from durable storage incur regional costs that appear in the ISR section of the usage dashboard.

Does a cache hit on Cloudflare Workers reduce the bill?

Not the request charge. Cloudflare documents that cache hits count as requests but do not consume CPU time, so a higher hit ratio saves CPU, while the request fees stay the same for the same traffic.

Sounds like what you need?

Tell me about your project or role – I’d love to hear from you.