Guides · February 14, 2023
Caching Strategies for Next.js Storefronts (ISR, Tags, Edge)
The right caching strategy for a Next.js storefront layers static ISR pages for category and product routes, tag-based revalidation for surgical cache-busting on webhook events, and edge caching for personalization-free fragments — with cart, price, and inventory always fetched fresh.
By Polo Themes
The short answer: cache aggressively at the page level with Incremental Static Regeneration, invalidate surgically with cache tags tied to your commerce webhooks, push read-mostly fragments to the edge, and never cache anything that reflects a specific cart, a specific customer, or a live inventory count. Get that split right and a Next.js storefront can serve most traffic from cache while still feeling instantaneous and accurate. Get it wrong, and you either serve stale prices to shoppers or pay a full origin round-trip on every page view, which is the exact performance tax headless commerce is supposed to eliminate.
Caching is the single highest-leverage decision in a headless storefront build, and it is also the one most teams under-design. It is tempting to either cache nothing (safe, slow) or cache everything with a short TTL (fast, occasionally wrong). Neither is a strategy — a real strategy assigns a caching tier to each *kind* of data based on how often it changes and how expensive it is to be wrong about. This guide walks through the tiers, the mechanics Next.js gives you to implement them, and the failure modes that catch teams off guard the first time a promotion goes live or a price changes mid-flash-sale.
Start With a Data Classification, Not a Caching Config
Before touching fetch options or route segment config, classify every piece of data your storefront reads. This single exercise prevents almost every caching bug that ships to production, because most caching bugs are really classification bugs — someone cached something that belonged in a different tier.
- Structural content — product descriptions, images, category trees, theme/layout data. Changes on a human timescale (minutes to days). Safe to cache long and revalidate on-demand.
- Merchandising data — prices, promotions, stock status shown as a badge ("only 3 left"). Changes on a business timescale (hours), sometimes instantly during a sale. Needs short TTLs or tag-based invalidation, not long static caching.
- Live inventory and checkout-critical numbers — exact stock count at add-to-cart time, real-time price at checkout. Must never be served from a page cache; fetch at request time or client-side against the commerce API directly.
- Session and cart state — cart contents, applied discount codes, logged-in customer data. Always per-request, always uncached, always excluded from any shared cache layer (including CDN and edge).
- Personalized content — recommendations keyed to browsing history, geo-priced storefronts, A/B test variants. Cacheable, but only when the cache key includes the dimension that varies (segment, locale, cohort) — never a single shared cache entry for personalized output.
Write this classification down as an actual table in your codebase or design doc before you write a single fetch call. Every caching decision below is really just "which tier does this belong to," applied consistently.
Tier 1: ISR for Structural and Merchandising Pages
Incremental Static Regeneration is the workhorse for product detail pages, category/collection pages, and marketing pages. The mental model: a page is built once, served as a static asset to every subsequent visitor, and regenerated in the background after a configured interval or an explicit trigger — visitors never wait on a rebuild, they simply get the previous version until the new one is ready.
Time-based revalidation as your safety net
Set a revalidate interval on every commerce data fetch as a baseline safety net, even when you plan to invalidate on-demand. Product pages might revalidate every 5-15 minutes; category listing pages, which change more often as items go in and out of stock, might revalidate every 1-5 minutes. Marketing and content pages that rarely change can go to an hour or more. The point of the time-based fallback is that on-demand invalidation systems fail silently — a missed webhook, a retry that never fires — and a time-based ceiling guarantees staleness never compounds past a bound you chose deliberately.
Do not cache the price on a long TTL alone
This is the mistake that causes real business damage: caching a product page with a 30-minute TTL, then running a flash sale that drops the price for 20 minutes. Some fraction of shoppers see the sale price late, and worse, some see it linger 20 minutes after the sale ends because the last regeneration happened to land mid-sale. Price and promotion badges need either a much shorter TTL than the rest of the page, or — better — tag-based invalidation so the moment the price changes in your commerce backend, the cached page is invalidated immediately rather than waiting out a timer.
Tier 2: Tag-Based Revalidation for Surgical Invalidation
Time-based revalidation answers "how stale can this get before I don't care." Tag-based revalidation answers a different question: "the moment this specific thing changes, how do I invalidate exactly the pages affected, and nothing else." The two are complementary, not competing — ship both.
The mechanic is straightforward: tag every cached fetch with an identifier tied to the entity it represents — a product ID, a collection handle, an inventory location. When your commerce backend fires a webhook (product updated, price changed, inventory adjusted, order placed), your Next.js app's webhook handler calls the tag-based revalidation function with that same tag. Every cached page or fetch response carrying that tag is invalidated on the next request, and only those pages — a price change on one SKU does not blow the whole site's cache.
Design your tags around entities, not routes
A common early mistake is tagging by URL path instead of by underlying entity. The problem: the same product often appears on more than one route — its own product page, a category listing, a homepage "featured" carousel, a search results page. If you tag by path, a price update only busts the product's own page and leaves three stale copies of the same stale price elsewhere on the site. Tag by product ID (and separately by collection ID, by "featured" list ID, and so on) so that a single webhook-triggered invalidation call reaches every page that happens to render that entity, regardless of how many routes reference it.
Webhooks are the trigger, not the cache
Your commerce platform's webhook system (whether that is Shopify's webhook topics, Medusa's event bus, or another platform's equivalent) is the source of truth for "something changed." The webhook handler's only job is to translate the event into the right set of tags and call the revalidation API — it should not attempt to recompute or push new content itself. Keep this handler thin and idempotent: webhooks can and will be delivered more than once, and re-invalidating an already-fresh tag is harmless, so don't build fragile de-duplication logic where a no-op retry would do.
Tier 3: Edge Caching for Read-Mostly, Non-Personalized Fragments
ISR caches at the origin/build layer; edge caching pushes already-rendered output geographically close to the shopper, cutting latency for the actual bytes crossing the network, not just avoiding origin compute. The two stack: an ISR-generated page can also be cached at the edge/CDN layer with its own headers, so a Tokyo shopper does not round-trip to a US-based origin just to fetch a page that was already fully rendered minutes ago.
The rule that keeps this tier safe: only cache fragments at the edge that are identical for every visitor who shares a cache key. A fully static product page with no personalization is a perfect candidate. A homepage with a "recently viewed" rail, a logged-in greeting, or geo-based pricing is not — unless you explicitly vary the cache key by the dimension that differs (locale, currency, segment) using Vary-style behavior or separate cache keys per variant. Caching a personalized fragment under one shared key is how one shopper's homepage ends up showing another shopper's browsing history — a real bug class in headless commerce, not a theoretical one.
Split the page into cacheable and dynamic slices
Rather than treating a page as one binary cache/no-cache unit, decompose it: the product gallery, description, and specs are structural and edge-cacheable; the "X people are viewing this" counter and the cart icon's item count are dynamic and must be excluded from the cached shell. React Server Components and streaming make this split natural — render the cacheable shell as a static or ISR page, and stream in the dynamic slivers (cart state, live stock count, personalized recommendations) as separate, uncached requests that resolve after the shell has already painted. The shopper gets a fast first paint from cache and correct, live numbers for the parts that matter, instead of a single all-or-nothing cache decision forcing a false choice between speed and accuracy.
Never Cache These, Even Under Pressure
A few categories of data should never enter a shared cache layer, full stop, regardless of how tempting the performance win looks under load.
- Cart contents and checkout session state — always per-request, always excludes shared caches; a cached cart response served to the wrong visitor is a data leak, not just a bug.
- Exact-time inventory at the moment of add-to-cart — a badge saying "low stock" can tolerate a few minutes of staleness; the number actually reserved during checkout cannot.
- Authenticated/account data — order history, saved addresses, payment methods. Treat any authenticated route as fully dynamic by default and opt in to caching only for data proven to be identical across all logged-in users, which is rare.
- Anything derived from request headers you have not explicitly keyed on — currency inferred from IP, locale from the Accept-Language header, A/B bucket from a cookie. If the cache key does not include the dimension, the cached response silently leaks one visitor's variant to the next.
A Concrete Rollout Order
For teams building or migrating a storefront to this model, a sensible build order avoids trying to design the entire caching system before shipping anything.
- Ship ISR with conservative time-based revalidation first on product and category pages — this alone typically cuts origin load and TTFB dramatically with almost no risk.
- Add webhook-triggered tag invalidation next, starting with price and inventory-status changes, since those are the highest-cost staleness bugs.
- Layer in edge caching for the fully static, non-personalized slices once the ISR/tag layer is stable and you can verify cache-hit ratios and correctness independently.
- Split personalized and dynamic fragments (cart badge, recommendations, live stock) out of the cached shell last, once you have confirmed the cacheable shell itself is correct and fast.
- Instrument cache-hit rate and staleness explicitly — log when a tag-based revalidation fires versus a time-based one, and alert on webhook delivery failures, since a silent webhook outage degrades gracefully into "just" time-based caching rather than breaking loudly.
This ordering matters because each stage is independently valuable and independently testable — you do not need the full tag-invalidation and edge-caching system designed before you ship the first, highest-value win. It also matches how most real storefront caching bugs get introduced: not by skipping a tier, but by adding a later tier before verifying the one beneath it is solid.
Where Design Assets Fit Into This
None of this caching architecture depends on which design system or component library sits on top of it — the same tag-and-tier discipline applies whether your product pages are built from a from-scratch layout or from a well-structured starting point. If you are prototyping the visual and UX layer of a storefront before wiring up the data layer, our Figma UI kits are a fast way to nail down product page, category grid, and cart layouts first, so the engineering work above lands on a design that has already been reviewed rather than one still being decided mid-build. For teams shipping on Shopify specifically rather than headless, the same caching instincts (know what changes, never cache checkout state) still apply, just handled largely by the platform — see our Shopify theme catalog if that is the simpler path for your store.
Frequently Asked Questions
Should I use ISR or full static generation for a storefront?
Full static generation (build-time only, no revalidation) only makes sense for a catalog small enough and stable enough that a full rebuild on every change is cheap and fast. Almost every real storefront benefits from ISR instead, since it gets the same served-from-cache performance while tolerating a catalog that changes continuously without requiring a full site rebuild for every price update.
How do cache tags interact with a CDN in front of Next.js?
Tag-based revalidation controls Next.js's own data and page cache; a CDN in front of it has its own cache layer with its own invalidation mechanism (often also supporting tag or surrogate-key based purging). For a fully correct setup, your webhook handler needs to invalidate both layers — the framework-level cache and the CDN — or set CDN TTLs short enough that framework-level correctness propagates within an acceptable window.
What is the biggest caching mistake teams make on their first headless storefront?
Caching an entire page — including cart state, personalized greetings, or live stock counts — under one shared cache key because it was the easiest thing to ship. It works fine in a demo with one visitor and breaks immediately at real traffic, either leaking one shopper's data to another or serving stale checkout-critical numbers. Classifying data by tier before writing the caching code, as outlined above, is the reliable way to avoid this.
Do I need Redis or another external cache store for this?
Not to start. Next.js's built-in data cache and ISR mechanics, combined with your CDN/edge layer, cover the large majority of storefront caching needs without an additional cache store. An external store like Redis becomes worth adding when you need cache state shared consistently across many server instances for something framework-level caching does not cover well — session data, rate limiting, or a custom personalization layer — not as a replacement for ISR and tag-based revalidation.