LUCENTCOMMERCEGET A FREE STORE AUDITFREE AUDIT

HEADLESS · PERFORMANCE · STOREFRONT API · 28 AUGUST 2026 · 9 MIN READ

Rendering strategy for a headless storefront

Server or edge is the wrong first question. What decides the page is how many requests have to reach the Storefront API at all, and which ones must never be cached.

Shopify's cart and checkout behind a front end you own

Render on the server and cache the result, and treat edge execution as the answer to the requests you could not cache rather than the default for all of them. A product page that is identical for every buyer should be served from a CDN without touching your application at all; a cart or an account page must be rendered per request and must never enter a shared cache. The interesting work is in the middle — price, stock, localised content — where the choice is a time-to-live you have chosen deliberately against the commercial cost of being wrong. Get that split right and the server-versus-edge decision turns out to affect a minority of your traffic, which is the correct size for a decision that expensive.

IN SHORT

  • Decide rendering per route, not per site: the same storefront can prerender its content pages, cache its product pages at the CDN, and render cart and account per request.
  • Shopify documents that Customer Account API data is never cached, and that a Storefront API `customer` query returns personalised data you must protect yourself with `CacheNone()` at both the sub-request and the full-page level.
  • Shopify documents that a Hydrogen sub-request with no cache option specified still caches, at `public, max-age=1, stale-while-revalidate=86399` — so a query that looks uncached is not.
  • Server-rendering with a private access token routes every buyer through your server, and Shopify documents that omitting the `Shopify-Storefront-Buyer-IP` header can cause throttled requests, limited bot protection and unauthenticated flows at checkout.
  • Shopify documents that Storefront API public access capacity scales with the number of buyers by IP address, so client-side and server-side rendering do not face the same limits.
  • Edge execution shortens the buyer-to-server hop and does nothing for the server-to-API hop, so it helps least on exactly the uncacheable pages people buy it for.
  • If your whole storefront is cacheable, the strongest argument for the rendering rebuild has just disappeared — a Liquid theme served from Shopify’s CDN was already doing that.

What the question is really asking

"Server or edge" is a question about where your code runs. The thing a buyer experiences is a question about how much work happens between their tap and the first byte, and there are four places that time goes: the network hop from the buyer to whatever answers, any hop from there to your data, the queries themselves, and the rendering. Edge execution shortens the first of those. It does not shorten the others, and in some layouts it lengthens them.

That matters because the pages people most want to speed up are the ones where the other three dominate. A cached product page never reaches your application at all, so where the application lives is irrelevant to it. A cart page has to query live data per buyer, so moving the renderer closer to the buyer moves it no closer to the data. The pages left over — uncacheable, but not because of anything personal — are the ones where running at the edge genuinely changes a number.

So the first task is not choosing a runtime. It is sorting every route into what can be built ahead of time, what can be cached and for how long, and what must be rendered fresh for one person. That sort is the rendering strategy. The runtime is an implementation detail of the third bucket.

Four strategies, and the route each one is for

Most storefronts need all four. A site that picks one and applies it everywhere is either paying to render pages that never change or serving one buyer a page built for another.

  • Prerendered at build. Content pages, landing pages, guides, anything editorially owned. The build produces HTML, the CDN serves it, and nothing runs per request. The cost is a rebuild when content changes, which is why this fits pages that change on a publishing schedule rather than a merchandising one.
  • Server-rendered, then cached. The default for product and collection pages. The first request renders; every subsequent one within the time-to-live is served from cache. This is where most of your traffic should land, and the design work is entirely in choosing the TTL and the invalidation.
  • Rendered per request. Cart, account, order status, anything addressed to a person. No shared cache, ever. Response times here depend on your query count and your API latency, not on your CDN.
  • Fetched in the browser. Stock on a variant, a recommendation strip, a recently-viewed rail. Deliberately client-side so the cacheable page stays cacheable, with the personal or volatile fragment arriving afterwards.

The cache is the strategy; the runtime is the leftovers

Hydrogen makes the caching model explicit rather than incidental, and its defaults are worth knowing before you reason about anything else. Shopify documents CacheShort() as public, max-age=1, stale-while-revalidate=9, CacheLong() as public, max-age=3600, stale-while-revalidate=82800, and CacheNone() as no-store. The detail that catches teams out is the default: Shopify documents that a sub-request with no cache option specified is cached at public, max-age=1, stale-while-revalidate=86399. A query you never thought about is being served stale for up to a day.

There are two layers here and they fail differently. Sub-request caching stores the API response; full-page caching stores the rendered HTML. A route can have every query correctly marked CacheNone() and still leak, because the assembled page went into a shared cache. Shopify documents the fix directly: set Cache-Control on the response — no-store where nothing may be stored anywhere, private, max-age=<seconds> where the browser may keep a copy but shared caches may not.

Once that is in place, count the traffic. On a typical catalogue storefront the overwhelming majority of requests are for pages that are identical for everyone, and those are answered by a CDN regardless of where your renderer runs. The pages that reach the renderer are a fraction. Choosing a runtime is a decision about that fraction, and it should be sized accordingly rather than argued about for a month.

The header that decides whether server-rendering behaves

This is the one thing on this page worth checking in your own codebase today, because it is invisible until traffic arrives.

When a storefront renders on the server with a private access token, Shopify sees every buyer as one client — your server. Shopify's documentation is explicit about the consequence: without the Shopify-Storefront-Buyer-IP header on requests that result from buyer traffic, Shopify cannot differentiate requests from different buyers, which can result in throttled API requests, limited bot protection, and unauthenticated flows at checkout.

That last item is the expensive one. A buyer arriving at checkout without their session carried across is a lost order, and it will present as an intermittent, unreproducible complaint rather than an error in your logs. The header is one line. Add it, and confirm it survives whatever proxy or CDN sits in front of your application, because a stripped or rewritten client IP produces the same symptom.

The contrast with client-side access is instructive. Shopify documents that Storefront API public access capacity scales with the number of buyers, based on their IP address, and that buyer traffic is not subject to a fixed per-minute request limit. Rendering in the browser inherits that model for free. Rendering on the server means you have to reconstruct it deliberately — which is a genuine cost of server-rendering, and one that almost never appears on an architecture diagram.

Streaming: what to await and what to let arrive late

Hydrogen fetches in loader functions, and the documented pattern is to defer non-critical data so the response can start before every query has finished. Used well this is the single largest time-to-first-byte win available on a server-rendered storefront. Used badly it produces a page that appears fast and is unusable for another second.

The test for whether something is critical is not how important it is commercially. It is whether the page is wrong without it. A product page is wrong without its title, its price and its main image — a visitor who sees a heading and an empty price box has been shown a broken page, and a crawler that renders before the fill-in has indexed one. A product page is not wrong without its review summary, its recommendation rail or its recently-viewed strip; those can arrive whenever they arrive.

The failure mode to watch is layout shift, because deferral trades server time for client instability. Anything deferred needs its space reserved in the initial HTML at the size it will occupy. If you cannot know that size, the thing you are deferring probably belongs below the fold, and if it is below the fold you could equally have fetched it in the browser and kept the page cacheable.

How to decide, in order

A sequence, because the answers narrow as you go and most teams never need to reach the last step.

  • Sort every route into prerendered, cached, per-request or client-fetched. Write it down. Disagreements about the runtime usually turn out to be disagreements about this list.
  • Set an explicit cache strategy on every Storefront API query, including the ones you think are trivial — the default is not "off".
  • Mark everything personal CacheNone() and set the response Cache-Control on those routes as well as the query. Two layers, both required.
  • Add Shopify-Storefront-Buyer-IP to every server-side request that came from a buyer, and verify the real client IP survives your proxy chain.
  • Defer only what the page is not wrong without, and reserve the space it will take.
  • Measure real time to first byte by region, from real traffic, not a synthetic test from one city. Only if the buyer-to-server hop is the dominant term — and only on routes that could not be cached — is edge execution the thing to buy.

The uncomfortable conclusion

Work through that list honestly on a typical mid-market catalogue and a pattern emerges: nearly everything is cacheable, the uncacheable remainder is cart and account, and cart and account are slow because of query count and API latency rather than geography. Which means the rendering strategy that wins is the boring one — server-render, cache hard, keep personal data out of shared caches — and it is available on every host, including the plainest single-region one.

It is also, notably, what a Liquid theme on Shopify's own CDN was already doing. If the case for your headless build rested on rendering speed and the honest strategy turns out to be "cache the pages", that case has not survived contact with the routing table. There are good reasons to go headless — a front end shared with a native app, an interface a theme genuinely cannot express, content composed from several systems — and this post is not an argument against any of them. It is an argument against buying a rendering architecture to solve a caching problem.

Whatever you choose, choose it per route and keep it reversible. A storefront where one template renders differently from the rest is not an inconsistency to be tidied up; it is the shape of a system that has been measured.

Questions this raises

Should a headless storefront render on the server or at the edge?

On the server, cached, for almost everything — and at the edge only for routes that cannot be cached and whose latency is dominated by the buyer-to-server network hop. Edge execution does not shorten the hop from your renderer to the Storefront API, which is usually the larger term on exactly the pages people buy it for.

What must never be cached on a Shopify headless storefront?

Anything addressed to a person: cart, customer, addresses, order history. Shopify documents that Customer Account API data is never cached, and that the Storefront API `customer` query returns personalised data you must protect with `CacheNone()`. Set the page-level `Cache-Control` as well — a correctly marked query inside a shared-cached page still leaks.

Is a query without a cache option uncached in Hydrogen?

No. Shopify documents the default for sub-requests as `public, max-age=1, stale-while-revalidate=86399`, which serves stale data for up to a day. Every query needs a deliberate strategy, including the ones that look too small to matter.

Why does my server-rendered storefront get throttled when the browser version did not?

Because Shopify can no longer tell your buyers apart. Public access capacity scales by buyer IP; a server using a private token presents as a single client unless you send `Shopify-Storefront-Buyer-IP`. Shopify documents throttling, weaker bot protection and unauthenticated checkout flows as the consequences of omitting it.

Does static generation work for a large catalogue?

For content and landing pages, yes. For thousands of products it turns every price or stock change into a rebuild, which is why server-rendering with a short time-to-live and webhook-driven invalidation is the usual answer for catalogue routes. Prerender what changes on a publishing schedule, cache what changes on a merchandising one.

How do I know if the rendering strategy is actually the problem?

Look at where the time goes before choosing anything. If most requests are served from cache and your slow pages are cart and account, the problem is query count and API latency. If time to first byte is high on cached pages, the problem is the CDN configuration. Neither is fixed by moving the renderer.

NEXT STEP

Free store audit

A senior Shopify engineer reviews your storefront, theme performance and checkout, then sends a prioritised list of fixes.