Skip to main content

03 - Shopify Hydrogen and Oxygen: Building a High-Performance React Storefront on the Edge

Hydrogen is Shopify's toolkit for headless storefronts. Learn how its cache works, how to add to the cart and what changes with version 2026-10.

What do you think about this article?

At a Glance

Hydrogen is Shopify's toolkit for headless storefronts. Learn how its cache works, how to add to the cart and what changes with version 2026-10.

Headless Commerce 10 min

Hydrogen is the toolkit with which Shopify helps you build headless storefronts: cart, variants, cache. Here you see what it does, how its cache works and which choices the move to version 2026-10 forces you to make.

shopify hydrogen shopify oxygen hydrogen framework storefront API stale-while-revalidate

What Hydrogen is today, and why the answer has changed

If you have read guides on Hydrogen written in past years, you will remember a React framework based on Remix. The official documentation, consulted on 2026-11-12, tells a different story: for version 2026-10 Hydrogen is a set of tools that handles “the parts of a storefront that are specific to Shopify and easy to get wrong” (cart, variant selection, filters, analytics and the routes that apps and checkout depend on), and leaves routing, rendering and interface to the framework you choose among Next.js, React Router, Nuxt, Astro and SvelteKit. Versions 2026-04 and earlier are now called legacy and have a migration guide.

The consequence for you is practical: before copying a listing from a guide, check which Hydrogen version it refers to. A tutorial on the old model uses functions, imports and folder conventions that in the current documentation are no longer the main path. This article shows the logic that remains valid (cache, cart, checkout) and says each time which version the code refers to.

The three-layer design described by the documentation helps you avoid confusion. Your framework owns routing, rendering and data; Hydrogen owns the Shopify logic, that is the Storefront API client, the cart and the routes needed by the store and the checkout (such as /api/cart and /checkout); Shopify provides the Storefront API, the Customer Account API and the checkout. The checkout, therefore, you do not write: you reach it. It is worth remembering before promising a client a fully custom checkout. For an example of a storefront with a framework of your choice, see the article on Next.js App Router.

The cache: why a product page must not call Shopify on every visit

Every product page, if you do nothing, generates a request to the Storefront API on every visit. For pages that rarely change it is a waste and a risk of exceeding request limits. The documentation on Hydrogen and Oxygen caching (in the legacy section) lists four strategies, all based on the Cache-Control header: CacheShort() is equivalent to public, max-age=1, stale-while-revalidate=9, that is ten seconds in all; CacheLong() to public, max-age=3600, stale-while-revalidate=82800, one day; CacheNone() to no-store; CacheCustom() leaves the header to you. When you do not choose, every sub-request uses max-age=1, stale-while-revalidate=86399.

The surprising part is stale-while-revalidate, defined in RFC 5861: after the max-age expires, for the stated duration the cache may still serve the old copy while it requests a new one from the origin. The visitor does not wait, and the next response is up to date. The listing below does not use Hydrogen: it is a model of that rule, written to read a header and say what state a response is in after a given number of seconds.

type Esito = 'fresh' | 'stale: served and revalidated in background' | 'expired: back to the origin';

// Reads max-age and stale-while-revalidate from a Cache-Control header.
function leggi(header: string) {
  const val = (nome: string) => Number(new RegExp(`${nome}=(\\d+)`).exec(header)?.[1] ?? 0);
  return { maxAge: val('max-age'), swr: val('stale-while-revalidate') };
}

// State of a cached response, `eta` seconds after it was produced.
function stato(header: string, eta: number): Esito {
  const { maxAge, swr } = leggi(header);
  if (eta <= maxAge) return 'fresh';
  if (eta <= maxAge + swr) return 'stale: served and revalidated in background';
  return 'expired: back to the origin';
}

const strategie = {
  CacheShort: 'public, max-age=1, stale-while-revalidate=9',
  CacheLong: 'public, max-age=3600, stale-while-revalidate=82800',
};

for (const [nome, header] of Object.entries(strategie)) {
  for (const eta of [0.5, 5, 11, 3_599, 7_200, 86_401]) {
    console.log(`${nome.padEnd(10)} age ${String(eta).padStart(6)} s -> ${stato(header, eta)}`);
  }
}

// Output:
// CacheShort age    0.5 s -> fresh
// CacheShort age      5 s -> stale: served and revalidated in background
// CacheShort age     11 s -> expired: back to the origin
// CacheShort age   3599 s -> expired: back to the origin
// CacheShort age   7200 s -> expired: back to the origin
// CacheShort age  86401 s -> expired: back to the origin
// CacheLong  age    0.5 s -> fresh
// CacheLong  age      5 s -> fresh
// CacheLong  age     11 s -> fresh
// CacheLong  age   3599 s -> fresh
// CacheLong  age   7200 s -> stale: served and revalidated in background
// CacheLong  age  86401 s -> expired: back to the origin

The lines that matter are the reading of the two numbers and the two thresholds in stato. A response with CacheShort is fresh only up to 1 second, remains servable but under revalidation up to 10, then it is expired and the user waits for the request to the origin; with CacheLong the freshness window is one hour and the revalidation window reaches one day. I find it useful to see that at 7,200 seconds a “long” page is already old but still served: if a product’s price changes, a customer may see an old one until the revalidation finishes. The output is deterministic, run with Node 24, and only simulates the RFC rule: it does not measure any Shopify store. How much the cache weighs on LCP is measured in the article on Core Web Vitals and SEO.

A loader with cache, and why not everything goes in the cache

The following listing shows how a strategy was assigned to a request in the legacy model: the storefront object, taken from the server context, has the query method to which you pass the GraphQL query, the variables and the cache option. Here a product’s title and variants use CacheLong() because they rarely change. If the product does not exist, the loader throws a real 404 response: a “not found” page with status 200 would be indexable as duplicate content.

// app/routes/products.$handle.tsx - Hydrogen 2026-04 and earlier style (legacy), with React Router
import type { LoaderFunctionArgs } from '@shopify/remix-oxygen';

const PRODUCT_QUERY = `#graphql
  query Product($handle: String!) {
    product(handle: $handle) {
      id
      title
      descriptionHtml
      variants(first: 20) {
        nodes { id availableForSale price { amount currencyCode } }
      }
    }
  }
`;

export async function loader({ params, context }: LoaderFunctionArgs) {
  const { storefront } = context;

  const { product } = await storefront.query(PRODUCT_QUERY, {
    variables: { handle: params.handle },
    cache: storefront.CacheLong(), // titles and descriptions rarely change
  });

  if (!product) throw new Response('Not Found', { status: 404 });
  return { product };
}

This block was not run: it requires a Shopify store with a token and the whole Hydrogen project, so it must be read as a schema to adapt, and the exact form of the imports depends on the Hydrogen version and on the framework. The principle, however, does not depend on the version: availability and price change more often than title and description, so they do not get the same strategy. The documentation also warns that Customer Account API data is never cached, because it is personal: the cart and the customer’s account go with CacheNone() or outside the cache. Caching a personalised response and serving it to another visitor is the most serious mistake this schema allows.

The cart: a GraphQL operation, and where the error arises

The Hydrogen cart is built, according to the dedicated page, in a “server-side first” way: HTML forms and ordinary HTTP requests, enhanced in the browser with optimistic updates and loading indicators. The stated advantage is that it keeps working if JavaScript fails to load. Underneath, the operation that adds a line is the cartLinesAdd mutation of the Storefront API, which accepts the cart identifier and a list of lines, each with merchandiseId (the variant) and quantity.

mutation CartLinesAdd($cartId: ID!, $lines: [CartLineInput!]!) {
  cartLinesAdd(cartId: $cartId, lines: $lines) {
    cart {
      id
      totalQuantity
      cost { totalAmount { amount currencyCode } }
    }
    userErrors { field message }
  }
}

# variables:
# { "cartId": "gid://shopify/Cart/...", "lines": [ { "merchandiseId": "gid://shopify/ProductVariant/...", "quantity": 1 } ] }

This too is a schema that was not run, because it requires a real cart and token. The line to watch is userErrors: the documentation defines it as a non-null list, so it always exists, and if the variant is not available or the quantity is not valid the error arrives in there, not as an HTTP error. Code that reads only cart would show an unchanged cart without explaining why. The other limit written in the documentation is that a single call cannot contain more than 250 lines.

The cost of the tie to Shopify

Hydrogen works with Shopify as backend and only with that: the Storefront API, the cart and the checkout belong to Shopify. If one day you change platform, the presentation layer (components, style, routing) can often be kept, while the whole data part has to be rewritten. It is an acceptable tie if you chose Shopify for its ecosystem (apps, payments, an already mature checkout) and uncomfortable if the reason you go headless is precisely not to depend on a single vendor: in that case look at Medusa or Saleor.

Then there is the matter of request limits. On the Storefront API page, for the Ruby client that runs on the server, you read that the limit is based on the IP address of whoever makes the request, that is the server’s; access with a public token, instead, has a capacity that grows with the number of buyers based on their IP. If the calls all start from your server, Shopify sees a single address, and that is why server-side caching is not a luxury. The method for deciding when to move to a richer setup is in the introductory article of the series.

If you start from a Liquid theme

A migration from a theme to a headless storefront is done step by step, not in a weekend. The most prudent scheme is to put the new storefront on a subdomain for only the pages where the gain is measurable (product and collection), leave the rest on the theme and compare real performance data for a few weeks before moving anything else. The checkout stays with Shopify in any case. The Hydrogen documentation describes the migration from legacy Hydrogen to version 2026-10, which is a different thing, and not a single procedure from Liquid to headless.

What you can do now

You can place Hydrogen in the version you are reading, recognise what Shopify leaves in your hands (routing, interface) and what it keeps (checkout, API), read a Cache-Control header with stale-while-revalidate and choose a strategy per type of data, and write a cart mutation that checks userErrors. In the next article we see the open-source alternative, Medusa.

Sources

Previous article ← MACH Architecture

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects