Most of the Gatsby commerce sites we get called into were built around 2020 on gatsby-source-shopify, and they all fail in the same two ways. First, the catalog is frozen in the last build: a price change or a sold-out variant stays wrong on the site until someone remembers to redeploy. Second, the build itself has grown to twenty minutes because every product, variant, and image is pulled and processed on every run.
Both problems come from the same mistake — treating all product data as build-time data. Commerce data has two very different halves, and a Gatsby storefront works well once you split them:
- Slow data (build time): which products exist, handles/URLs, titles, descriptions, images, collections, SEO copy. Changes a few times a week. Belongs in the static HTML, because that is what search engines and AI crawlers read.
- Fast data (request time): price, currency, inventory, variant availability, discounts. Changes constantly and must never be cached into HTML.
This tutorial shows the split in a Gatsby 5 + Shopify stack, then covers cart state, checkout handoff, and the rebuild pipeline. The patterns transfer to other headless commerce backends; only the client calls change.
1. Source the slow half at build time
Keep gatsby-source-shopify (it is still maintained by Shopify and tracks the Admin API), but stop asking it to be your source of truth for prices. Restrict what it pulls so the build stays fast:
// gatsby-config.js
module.exports = {
plugins: [
{
resolve: 'gatsby-source-shopify',
options: {
storeUrl: process.env.SHOPIFY_STORE_URL,
apiKey: process.env.SHOPIFY_ADMIN_API_KEY,
password: process.env.SHOPIFY_ADMIN_PASSWORD,
shopifyConnections: ['collections'],
downloadImages: false,
typePrefix: 'Shopify'
}
}
]
};
Two options there are the difference between a four-minute build and a twenty-minute one.
downloadImages: false stops Gatsby from pulling every product image through sharp. Shopify's CDN already does resizing and format negotiation via URL parameters, so use it directly and let the browser do the rest:
const src = (url, w) => `${url}&width=${w}`;
export const ProductImage = ({ url, alt }) => (
<img
src={src(url, 800)}
srcSet={[400, 800, 1200].map((w) => `${src(url, w)} ${w}w`).join(', ')}
sizes="(max-width: 700px) 100vw, 700px"
alt={alt}
width={800}
height={800}
loading="lazy"
decoding="async"
/>
);
Keep gatsby-plugin-image only for the handful of images that drive LCP — a hero or the first product image above the fold — and mark those loading="eager" with fetchPriority="high". Lazy-loading your LCP image is still the most common self-inflicted Core Web Vitals wound on commerce sites.
Dropping shopifyConnections: ['orders'] matters too. Orders are never needed to render a storefront, and pulling them is both slow and a needless exposure of customer data into your build cache.
2. Generate product pages from the static catalog
Nothing exotic here — the point is that the page template renders a complete, indexable product page with no price in the HTML shell that can go stale, and a price slot that fills in at runtime.
// gatsby-node.js
exports.createPages = async ({ graphql, actions }) => {
const { data } = await graphql(`
{
allShopifyProduct(filter: { status: { eq: "ACTIVE" } }) {
nodes { handle }
}
}
`);
data.allShopifyProduct.nodes.forEach(({ handle }) => {
actions.createPage({
path: `/products/${handle}/`,
component: require.resolve('./src/templates/product.js'),
context: { handle }
});
});
};
If your catalog is in the thousands, put the long tail behind Deferred Static Generation (defer: true for products outside your top collections) so the build only renders what gets traffic. That is a real Gatsby advantage over a plain SSG here, and it is worth keeping even if you never use anything else from the framework.
3. Fetch the fast half at runtime
The runtime call goes to the Storefront API with a public storefront access token — not the Admin key. Storefront tokens are designed to be shipped to browsers and are scoped to reading products and writing carts. Never let an Admin API key reach client bundles; if one ever has, rotate it before you do anything else.
// src/lib/storefront.js
const ENDPOINT = `https://${process.env.GATSBY_SHOPIFY_DOMAIN}/api/2025-07/graphql.json`;
export async function storefront(query, variables = {}) {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Storefront-Access-Token': process.env.GATSBY_SHOPIFY_STOREFRONT_TOKEN
},
body: JSON.stringify({ query, variables })
});
if (!res.ok) throw new Error(`Storefront ${res.status}`);
const json = await res.json();
if (json.errors) throw new Error(json.errors.map((e) => e.message).join('; '));
return json.data;
}
Pin the API version in the URL. Shopify retires versions on a schedule, and an unpinned or long-stale version is how a storefront silently breaks eighteen months after handoff. Put the version in one constant and add a calendar reminder to bump it.
Then a small hook per product page:
// src/hooks/use-live-variants.js
import { useEffect, useState } from 'react';
import { storefront } from '../lib/storefront';
const QUERY = `
query Live($handle: String!) {
product(handle: $handle) {
variants(first: 100) {
nodes {
id
availableForSale
quantityAvailable
price { amount currencyCode }
}
}
}
}
`;
export function useLiveVariants(handle) {
const [state, setState] = useState({ status: 'loading', variants: null });
useEffect(() => {
let cancelled = false;
storefront(QUERY, { handle })
.then((d) => {
if (!cancelled) setState({ status: 'ready', variants: d.product.variants.nodes });
})
.catch(() => {
if (!cancelled) setState({ status: 'error', variants: null });
});
return () => { cancelled = true; };
}, [handle]);
return state;
}
Render the three states honestly
This is where most implementations go wrong. They render the build-time price as a placeholder, then swap it when the live fetch lands. That produces a visible price flicker, a layout shift, and — occasionally — a customer who saw a stale price and calls you about it.
Render a skeleton instead, sized to the final text so nothing shifts:
const { status, variants } = useLiveVariants(handle);
const variant = variants?.find((v) => v.id === selectedId);
if (status === 'loading') return <span className="price price--skeleton" aria-busy="true"> </span>;
if (status === 'error') return <a href={shopifyProductUrl}>View price on our store</a>;
return (
<span className="price">
{new Intl.NumberFormat(locale, { style: 'currency', currency: variant.price.currencyCode })
.format(variant.price.amount)}
{!variant.availableForSale && <em> — sold out</em>}
</span>
);
The error branch matters. A storefront whose price fetch fails should degrade to a working link, not to a blank space or a broken add-to-cart button.
For SEO and rich results, emit Product JSON-LD at build time with the build-time price and priceValidUntil set a few days out. That keeps structured data valid without pretending the HTML is live, and a webhook-driven rebuild (section 5) keeps it close to true.
4. Cart state and checkout handoff
Do not build your own checkout. The cart lives in the Storefront API's Cart object; you hold only the cart ID.
const CART_CREATE = `
mutation Create($lines: [CartLineInput!]) {
cartCreate(input: { lines: $lines }) {
cart { id checkoutUrl totalQuantity }
userErrors { field message }
}
}
`;
export async function addToCart(cartId, merchandiseId, quantity = 1) {
if (!cartId) {
const d = await storefront(CART_CREATE, { lines: [{ merchandiseId, quantity }] });
localStorage.setItem('cartId', d.cartCreate.cart.id);
return d.cartCreate.cart;
}
const d = await storefront(CART_LINES_ADD, { cartId, lines: [{ merchandiseId, quantity }] });
return d.cartLinesAdd.cart;
}
Four things that bite in production:
- Carts expire. A stored cart ID stops resolving after a period of inactivity. Every read must handle
cart === nullby clearinglocalStorageand starting fresh, rather than throwing oncart.lines. - Never trust the client for totals. Display
cart.cost.subtotalAmountas returned by the API. Locally computed totals drift the moment a discount or tax rule changes. checkoutUrlis the exit. Send the customer to it with a plain navigation, not a client-side route transition. It is a different origin; Gatsby's router must not intercept it.- Wrap the provider correctly. Put your cart context in
wrapRootElement(in bothgatsby-browser.jsandgatsby-ssr.js), neverwrapPageElement, or the cart resets on every internal navigation.
Checkout on Shopify's domain also means PCI scope, fraud checks, wallets, and tax stay their problem. That is the whole point of the split.
5. Rebuilds: webhooks, not cron
A nightly rebuild is both too slow for a price change and too frequent for a catalog that did not change. Wire Shopify webhooks to your host's build hook instead:
products/create,products/delete,collections/update→ trigger a build. These change the page set, and only a build can add or remove URLs.products/update→ debounce. Merchandisers save products repeatedly; without debouncing you get twelve builds in an hour. A small serverless function that records "dirty" and fires at most one build every 15 minutes is enough.inventory_levels/update→ ignore. Inventory is fast data; the runtime fetch already handles it. This one webhook is responsible for most runaway build minutes on Gatsby commerce sites.
Add a build assertion so a bad source fetch cannot ship an empty catalog:
// gatsby-node.js
exports.onPostBuild = async ({ graphql, reporter }) => {
const { data } = await graphql(`{ allShopifyProduct { totalCount } }`);
const min = Number(process.env.MIN_PRODUCTS || 25);
if (data.allShopifyProduct.totalCount < min) {
reporter.panic(`Only ${data.allShopifyProduct.totalCount} products sourced (expected >= ${min}). Refusing to deploy.`);
}
};
We pair that with a Playwright smoke test in CI that loads one product page, waits for the live price to resolve, adds to cart, and asserts the checkoutUrl points at the Shopify domain. Those four assertions catch nearly every regression that actually costs money.
6. When Gatsby is the wrong answer here
Be honest about the boundary. This architecture holds up well for catalogs up to a few thousand SKUs where content and merchandising copy matter, and where the marketing site and the store are the same codebase. It is a poor fit if you need customer accounts, order history, subscriptions, or per-customer B2B pricing on your own domain — at that point most of the page is per-request data, the static layer buys you almost nothing, and either Shopify's own Hydrogen/Oxygen stack or a server-rendered framework is the better tool.
If you already have a working Gatsby storefront, the fix is rarely a replatform. It is usually these four changes: stop downloading images, drop the orders connection, move price and inventory to a runtime fetch, and unhook the inventory webhook from your build. We have taken twenty-minute builds to under five and ended stale-price support tickets in about a week of work without touching the front end's design.
If you want a second pair of eyes on a Gatsby storefront — build times, stale data, or a replatform decision you are trying to make responsibly — get in touch and tell us what your build log looks like.