Speed up your Hydrogen e-commerce site with Netlify's advanced caching primitives

by Philippe Serhal

Hydrogen is a framework designed specifically for developing customized, modern, performant e-commerce sites powered by the Shopify platform. But with Netlify’s advanced caching primitives, we can take performance even further.

Countless studies have found that improvements in page load time directly lead to improvements in conversion rate. If you’re running an e-commerce storefront, you have a vested interest in squeezing the best possible performance out of your pages.

In this guide, we’ll show you a standards-based approach where static and infrequently changing content is cached with HTTP response headers and uncacheable content (say, a cart and session) is then loaded dynamically from the browser, combining the best of both worlds.

Read on to find out how to get content in front of your customers faster.

#A quick primer on Hydrogen rendering

Hydrogen is based on the Remix framework. Remix does not support pre-rendering pages at build time. When initially loading a page from a browser, Remix uses Server-Side Rendering (SSR) to render it on the fly from the server (an edge function running on a Netlify CDN edge node close to the user).

Hydrogen uses Remix’s SSR streaming to stream this response to the browser, allowing content to start being shown as soon as possible. Any components on the page using React Suspense are then streamed in as they become ready, after the closing </html> tag in the same response (yes, that’s how it works under the hood!).

This pattern has many benefits, but one downside is that despite having separated the fast initial shell of the page from the more dynamic content loaded in later, the initial shell isn’t cacheable on its own without having the dynamic content tag along!

What about other frameworks?

The same would be true when using React Suspense directly without a meta-framework like Remix.

The same is true with Next.js’s experimental Partial Pre-rendering (PPR) feature, which uses React Suspense.

On the other hand, Astro’s experimental Server Islands are cacheable out of the box. They use an approach much like the one in this guide.

#Getting started

This guide assumes you already have a Hydrogen site deployed to Netlify. If that isn’t the case, you can follow this guide first then come back here to set up caching.

Just want to deploy a new site with all the caching bells and whistles? Click the button below. All the steps below will have been completed for you.

Deploy to Netlify

If you prefer to just follow along without deploying your own site, check out our demo store. It follows all the patterns described below. Just keep in mind that you won’t be able to try on-demand revalidation for yourself.

#Caching pages with Hydrogen

OK, so how do we cache pages on a Hydrogen site?

  1. Avoid React Suspense for content you don’t want to cache
  2. Fetch that data from the browser instead with Remix’s useFetcher hook
  3. Set cache response headers

#1. Avoid React Suspense for content you don’t want to cache

First, stop using Remix defer() for any data you don’t want to cache. For example, you shouldn’t cache personalized content like a cart or a logged-in user’s session.

app/routes/your-page.tsx
import { defer, type LoaderFunctionArgs } from "@netlify/remix-runtime";
export async function loader({ context }: LoaderFunctionArgs) {
return defer({
cart: context.cart.get(),
isLoggedIn: context.customerAccount.isLoggedIn(),
returnPolicy: context.storefront.query(/* ... */),
});
}

#2. Fetch the above data from the browser with Remix’s useFetcher()

For each unique piece of data you updated in step 1 above, make sure your app provides an “endpoint” to fetch it. With Remix, you can fetch any route that has a loader(), so you may not need to make any changes in most cases. For example, this /cart route works as is:

app/routes/cart.tsx
import { json, type LoaderFunctionArgs } from "@netlify/remix-runtime";
export async function loader({ context }: LoaderFunctionArgs) {
const existingCart = await context.cart.get();
if (existingCart) {
return json(existingCart);
}
const { cart } = await context.cart.create({});
return json(cart);
}

Now we can fetch this data from the browser. Let’s use React Context to avoid duplicating code, ensure we handle errors, ensure it only runs in the browser, and ensure type safety:

app/components/CartProvider.tsx
import { createContext, useContext, useEffect } from "react";
import { useFetcher } from "@remix-run/react";
import type { Cart } from "@shopify/hydrogen-react/storefront-api-types";
const CartContext = createContext<Cart | undefined>(undefined);
export function CartProvider({ children }: { children: React.ReactNode }) {
const fetcher = useFetcher();
// This will only run in the browser, not during SSR
useEffect(() => {
// Don't load if already loaded or currently loading
if (fetcher.data || fetcher.state === "loading") return;
fetcher.load("/cart");
}, [fetcher]);
return <CartContext.Provider value={fetcher.data}>{children}</CartContext.Provider>;
}
export function useCart() {
return useContext(CartContext);
}

Provide the cart context in your app root:

app/root.tsx
import { CartProvider } from "~/components/CartProvider";
/* ... */
<body>
{data ? (
<CartProvider>
<PageLayout {...data}>{children}</PageLayout>
</CartProvider>
) : (
children
)}
/* ... */
</body>;
/* ... */

Using AnalyticsProvider?

If you’re using AnalyticsProvider, check out this approach for composing these two context providers.

Finally, we can use this context instead of React Suspense everywhere this data is used:

app/components/Header.tsx
<Suspense fallback={<CartBadge count={null} />}>
<Await resolve={cart}>
{(cart) => {
const cart = useCart();
if (!cart) return <CartBadge count={0} />;
return <CartBadge count={cart.totalQuantity ?? 0} />;
}}
</Await>
</Suspense>

Add a fallback

Make sure to handle the undefined case that was previously handled by the <Suspense>’s fallback prop!

#3. Set cache response headers

At this point, you can set any standard HTTP caching headers and compose them to meet your site’s needs.

To cache a page, export a Remix headers() function from the route. For example:

app/routes/_index.tsx
import { type HeadersFunction } from "@netlify/remix-runtime";
export const headers: HeadersFunction = () => ({
"Cache-Control": "public, max-age=0, must-revalidate",
"CDN-Cache-Control": "public, max-age=3600",
"Netlify-Vary": "query=_data",
});

In this example, we instruct the Netlify CDN to cache the homepage for up to one hour and instruct browsers to use cached content indefinitely as long as they check with Netlify that it isn’t stale.

The Netlify-Vary header is needed because Remix uses each of your app’s routes for two purposes: rendering pages and fetching that page’s data. Remix uses a ?_data=... query string parameter to identify which to load (think of it like an Accept header). Luckily, Netlify-Vary solves this perfectly by caching them separately. Make sure you always set this header, on page responses and on loader responses.

If you load this page multiple times with your browser’s developer tools open, you should see something like this:

A browser's dev tools network pane shows repeated requests to the homepage, with the first request being slow (600+ ms) and subsequent requests being much faster (~30 ms)

The first request to the homepage (/) invokes the SSR edge function and results in a response time over 600 ms. Since we’ve instructed this page to be cached for an hour on the CDN, subsequent requests are an order of magnitude faster, clocking in around 30 ms.

#What about CSR? Let’s cache data too

Now, your customers don’t hit reload over and over. You may have noticed that if you make the changes above and click around your site like an actual person, response times aren’t quite as fast as the 30 ms on reload and if you inspect the requests you may notice no caching is leveraged.

This is because after the initial page load and app hydration, Remix uses client-side navigation, determines what data to fetch for the next page, fetches it, then renders the new page on the client. This is commonly called Client-Side Rendering (CSR).

If we want to leverage caching for CSR, just set cache headers on loader responses. For example:

app/routes/policies._index.tsx
import { json, type LoaderFunctionArgs } from "@netlify/remix-runtime";
export async function loader({ context }: LoaderFunctionArgs) {
const data = await context.storefront.query(POLICIES_QUERY);
const policies = Object.values(data.shop ?? {});
if (policies.length === 0) {
throw new Response("No policies found", { status: 404 });
}
return json(
{ policies },
{
headers: {
"Cache-Control": "public, max-age=0, must-revalidate",
"CDN-Cache-Control": "public, max-age=86400",
"Netlify-Vary": "query=_data",
},
}
);
}

Try it again and you’ll see that client-side navigation to the /policies route is now consistently fast because it leverages data cached on the Netlify CDN.

#Stale-While-Revalidate (SWR) caching with Hydrogen

Stale-While-Revalidate or SWR is a pattern that lets you render a page on the fly via SSR on the first request to a route (for that CDN node), and subsequently always serve responses directly from the CDN edge cache and control when that page is regenerated in the background. Read more about it in our docs.

To use it, just add the directive on any page or loader:

app/routes/_index.tsx
import { type HeadersFunction } from "@netlify/remix-runtime";
export const headers: HeadersFunction = () => ({
"Cache-Control": "public, max-age=0, must-revalidate",
"CDN-Cache-Control": "public, max-age=3600",
"CDN-Cache-Control": "public, max-age=3600, stale-while-revalidate=120",
"Netlify-Vary": "query=_data",
});

In this example, we:

  • Instruct the Netlify CDN to serve cached responses for the homepage (/) even if they are stale (older than one hour), and initiate a revalidation in the background if it is stale
  • Instruct the Netlify CDN not to serve stale responses if it’s been more than two minutes since we initiated the background revalidation (this is a failsafe)
  • Instruct browsers to use cached content indefinitely as long as they check with Netlify that it isn’t stale.

If you load this page multiple times, here’s what happens:

  • The first request will invoke your Hydrogen SSR edge function.
  • Subsequent requests (that hit the same CDN node) will respond with cached content immediately.
  • Requests an hour or more later will also respond with stale cached content immediately, but within a second or two subsequent requests will respond with updated (but still cached!) content immediately.

#Vary and Netlify-Vary with Hydrogen

Netlify supports Vary as well the more powerful Netlify-Vary header. Let’s take a look at a couple common use cases.

#Search page

On a search page, you may want something like this:

app/routes/search.tsx
import { json, type LoaderFunctionArgs } from "@netlify/remix-runtime";
export async function loader({ context }: LoaderFunctionArgs) {
return json(
{
/* ... */
},
{
headers: {
"Cache-Control": "public, max-age=0, must-revalidate",
"CDN-Cache-Control": "public, max-age=300",
"Netlify-Vary": "query=_data|q|limit|predictive",
},
}
);
}

This will cache search results for 5 minutes, separately by search query and options, and separately for page responses and data responses.

#Product detail page

Out of the box, Hydrogen distinguishes product variants by breaking out a given variant’s options in the URL query string, e.g. /products/sweater?Size=medium&Color=blue. To cache these separately and efficiently, we can vary on these query string params:

"Netlify-Vary": "query=_data|Size|Color",

This works, but it is isn’t very robust, since each product can have its own set of options, and these can change at any time. How do we solve this once and never worry about it again? When rendering, we already access to the product data that we’ve loaded from the Shopify GraphQL API, so we can read the options from there and derive the vary header from those:

app/routes/search.tsx
import { json, type LoaderFunctionArgs } from "@netlify/remix-runtime";
export async function loader({ params: { handle }, context }: LoaderFunctionArgs) {
const { product } = await context.storefront.query(PRODUCT_QUERY, { variables: { handle } });
const optionNames = product.options.map(({ name }) => name);
return json(
{
/* ... */
},
{
headers: {
/* ... */
"Netlify-Vary": `query=${["_data", ...optionNames].join("|")}`,
},
}
);
}

#On-demand cache invalidation on updates to your Shopify store

We can take this even further. For content that is dynamic but isn’t constantly changing, we can configure an aggressive caching policy and configure a Shopify webhook handler on our Netlify site that performs on-demand tag-based invalidation of stale content.

When returning product data, add a cache tag uniquely identifying that product:

app/routes/products.$handle.tsx
import { json, type LoaderFunctionArgs } from "@netlify/remix-runtime";
export async function loader({ params: { handle }, context }: LoaderFunctionArgs) {
const { product } = await context.storefront.query(PRODUCT_QUERY, { variables: { handle } });
return json(
{
/* ... */
},
{
headers: {
"Cache-Control": "public, max-age=0, must-revalidate",
"CDN-Cache-Control": "public, max-age=300",
"Netlify-Vary": "query=_data",
"Cache-Tag": `product:${product.id}`,
},
}
);
}

(The above assumes you’re using the pro-tip below to share your loader’s headers with your page’s.)

What about pages with multiple products?

For a page listing multiple products, you can choose from a few options. You could follow this same approach and include a tag for each product id on the page. You could set a general cache tag on these pages (e.g. products) and always invalidate this tag on any product update. You could set a relatively low TTL (e.g. max-age=300, 5 mins) on these pages and not bother with on-demand revalidation at all. The choice is yours.

Then, add a Shopify product update webhook event handler. Let’s use a Netlify serverless function:

Terminal window
npm install @netlify/functions

There’s a bit of boilerplate required here to implement this securely. You can copy this as is into a new file:

netlify/functions/shopify-webhook.ts
import { purgeCache } from "@netlify/functions";
import { createHmac } from "node:crypto";
const isHmacValid = (hmac: string | null, secret: string, rawBody: string): boolean => {
const expectedHmac = createHmac("sha256", secret).update(rawBody, "utf8").digest("base64");
return hmac === expectedHmac;
};
export default async function shopifyWebhookHandler(req: Request): Promise<Response> {
const shopifyWebhookSecret = process.env.SHOPIFY_WEBHOOK_SECRET;
if (!shopifyWebhookSecret) {
console.error("Received a webhook event but SHOPIFY_WEBHOOK_SECRET is not set");
return new Response("Unauthorized", { status: 401 });
}
const topic = req.headers.get("X-Shopify-Topic");
// Change this if you want to handle other event types
if (topic !== "products/update") {
console.warn("Ignoring unexpected webhook topic", { topic });
return new Response("Accepted", { status: 202 });
}
const hmac = req.headers.get("X-Shopify-Hmac-Sha256");
const rawBody = await req.text();
if (!isHmacValid(hmac, shopifyWebhookSecret, rawBody)) {
return new Response("Unauthorized", { status: 401 });
}
const { admin_graphql_api_id: productId } = JSON.parse(rawBody);
// NOTE: This is the same tag we set on the loader response!
// You may want to extract a helper function to keep them in sync.
await purgeCache({ tags: [`product:${productId}`] });
return new Response("Accepted", { status: 202 });
}

Then, create the Shopify webhook:

  1. In your Shopify store, navigate to Settings > Notifications > Webhooks
  2. Click “Create webhook”
  3. Choose the “Product update” event (or whichever applies to your case)
  4. Enter https://<your-site-name>.netlify.app/.netlify/functions/shopify-webhook for the URL, where <your-site-name> is your site name (which is also its subdomain) and shopify-webhook is your webhook handler function name
  5. Choose an API version. This doesn’t need to match the API version used by your site. We recommend the latest stable version.
  6. Click “Save”

On the same page, below “Your webhooks will be signed with” you’ll find your Shopify webhook secret. Copy this to your clipboard and run:

Terminal window
netlify env:set SHOPIFY_WEBHOOK_SECRET "shopify-webhook-secret-from-your-clipboard"

Netlify CLI

Commands starting with netlify <...> use the powerful Netlify CLI. Install it by running:

Terminal window
npm i -g netlify-cli@latest

You’re all set! If you deploy these changes, cached contents for /products/abc will be immediately purged across the Netlify global CDN.

Note that caching is scoped by deploy context and domain, so if you’re clicking around a Deploy Preview (e.g. https://deploy-preview-1234--example.netlify.app/products/abc) but you only have a webhook configured for https://example.netlify.app, that Deploy Preview will be unaffected. Luckily, you can create as many webhooks as you want, with different URLs.

If you’re curious, you can stream the webhook handler function logs locally:

Terminal window
netlify logs:function shopify-webhook

#Pro-tip: reuse your Hydrogen cache config across your app

A more realistic example of the above would show both the loader() response and the page response, both specifying similar or identical cache headers. Luckily, Remix provides mechanisms for referencing loader headers in page headers and even parent route headers. A more convenient setup would look something like this:

app/routes/policies._index.tsx
import { json, type HeadersFunction, type LoaderFunctionArgs } from "@netlify/remix-runtime";
export const headers: HeadersFunction = ({ parentHeaders, loaderHeaders }) => ({
"Cache-Control": "public, max-age=0, must-revalidate",
"CDN-Cache-Control": "public, max-age=60",
"Netlify-Vary": "query=_data",
...Object.fromEntries(parentHeaders),
...Object.fromEntries(loaderHeaders),
});
export async function loader({ context }: LoaderFunctionArgs) {
const data = await context.storefront.query(POLICIES_QUERY);
const policies = Object.values(data.shop ?? {});
if (policies.length === 0) {
throw new Response("No policies found", { status: 404 });
}
return json(
{ policies },
{
headers: {
"Cache-Control": "public, max-age=0, must-revalidate",
"CDN-Cache-Control": "public, max-age=600, stale-while-revalidate=3600",
"Netlify-Vary": "query=_data",
},
}
);
}
export default function Policies() {
const { policies } = useLoaderData();
return <div className="policies">{/* ... */}</div>;
}

Now the page will inherit its loader’s headers, unless overridden. It will even inherit any headers from its ancestor routes. You could use this to define default page cache headers across your whole site and only override them when necessary.

Unfortunately, there is no such mechanism for loader headers. The only tool in the Remix toolbelt to help with duplication and consistency here is to intercept all data requests and attach headers. You can do this by updating your app/entry.server.jsx:

app/entry.server.tsx
export { default } from "virtual:netlify-server-entry";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@netlify/remix-runtime";
export function handleDataRequest(response, { request }) {
// If a loader has defined custom cache headers, assume they know what they're doing. Otherwise,
// apply these defaults. We do this here because there is no Remix mechanism to define default
// loader headers, nor is there a mechanism for routes to inherit parent route loader headers.
const hasCustomCacheControl =
response.headers.has("Netlify-CDN-Cache-Control") ||
response.headers.has("CDN-Cache-Control") ||
response.headers.has("Cache-Control");
const isRedirect = !response.headers.has("X-Remix-Response");
const isCacheable = request.method === "GET";
if (!hasCustomCacheControl && isCacheable && !isRedirect) {
response.headers.set("Cache-Control", "public, max-age=0, must-revalidate");
response.headers.set("CDN-Cache-Control", "public, max-age=3600, stale-while-revalidate=120");
response.headers.set("Netlify-Vary", "query=_data");
}
return response;
}

For full composability, export default caching headers from a helper module and extend them where needed.

#Wrapping up

In this guide, we demonstrated how to leverage Netlify’s advanced caching primitives to get Hydrogen e-commerce storefront pages in front of customers an order of magnitude faster.

We accomplished this by using a hybrid approach where pages and data are cached with HTTP response headers—as with SSG—and uncacheable components and data are loaded from the browser—as with CSR. The end result is similar to Partial Prendering, but the implementation builds on the web platform.

#Live demos

Compare two versions of the same demo storefront: