Tags: web-dev commerce reference
Reference - Hydrogen
Framework: Hydrogen 2026.4 — React Router 7, Vite, Oxygen. Storefront API 2026-04
Checked: 2026-08-18
Shopify’s React storefront framework: React Router 7 running in a worker, with a Shopify-shaped
contextinjected into every loader and a cache in front of every API call. Learncontext.storefront,context.cartand the cache strategies and you have most of it.
Two things are called Hydrogen
HYDROGEN HYDROGEN TOOLKIT
the framework — this note the developer preview
2026.4.x, calendar-versioned announced June 2026
a complete React framework commerce primitives, no framework
React Router 7 · Vite · Oxygen bring Next.js · SvelteKit · Astro · Nuxt
decides routing, data, deploy decides nothing but commerce
stable, in production API surface still moving
Same team, same name, different products. The framework is not deprecated and there is no forced migration — the pivot and how to choose are at the end, under . Everything between here and there is the stable framework.
§0 The one idea
Hydrogen is React Router with a Shopify-shaped context object, running in a worker, with a cache in front of every Shopify API call.
React Router hands every loader {request, params, context} and leaves context for you to fill. Hydrogen fills it — and that object is the framework. Almost every Hydrogen feature is reached through a property on it.
REACT ROUTER 7 routing · loaders and actions · form mutations ·
streaming server-side rendering · automatic revalidation
OXYGEN WORKER V8 isolate, not Node. `fetch`, `caches`, `waitUntil`.
no filesystem, no long-running process
HYDROGEN context.storefront typed Storefront API client + cache
context.cart server-driven cart, id in the session
context.customerAccount OAuth against the Customer Account API
context.session cookie session you own
context.env typed environment variables
plus: analytics + consent, Money, Image, Pagination,
sitemaps, redirects, content security policy nonces
The top layer is documented in Reference - React Router and deliberately not repeated below.
vs React Router: nothing about routing, data loading or forms changes. What changes is that the request runs in a worker rather than Node, and that context arrives already wired to Shopify.
vs a Next.js storefront calling the Storefront API yourself: the same GraphQL, plus the cache layer, the cart semantics, the OAuth flow and the analytics wiring that you would otherwise write and maintain.
1. The smallest working form
server.ts is the entire entry point. This is complete — not an excerpt.
import * as serverBuild from 'virtual:react-router/server-build';
import {createRequestHandler, storefrontRedirect} from '@shopify/hydrogen';
import {createHydrogenRouterContext} from '~/lib/context';
export default {
async fetch(request: Request, env: Env, executionContext: ExecutionContext) {
const hydrogenContext = await createHydrogenRouterContext(
request, env, executionContext,
);
const handleRequest = createRequestHandler({
build: serverBuild, // React Router's compiled routes
mode: process.env.NODE_ENV,
getLoadContext: () => hydrogenContext, // ← the whole trick
});
const response = await handleRequest(request);
if (hydrogenContext.session.isPending) { // session touched during the request
response.headers.set('Set-Cookie', await hydrogenContext.session.commit());
}
if (response.status === 404) {
// ask Shopify whether the admin has a redirect for this path
return storefrontRedirect({request, response, storefront: hydrogenContext.storefront});
}
return response;
},
};Three things worth noticing.
It’s a fetch handler, not a server. No listen, no Express. The worker is handed a Request and returns a Response, which is why Oxygen, Cloudflare Workers and the local MiniOxygen dev server all run it unchanged — Edge Computing.
storefrontRedirect only runs on a 404. URL redirects configured in the Shopify admin live in Shopify, not in your route table, so the framework checks them after your app has failed to match. Cheap, because it only fires on misses — Redirects and Link Equity.
Hydrogen’s createRequestHandler wraps React Router’s. It adds request validation, double-slash normalisation, a powered-by header, and — since 2026.4 — mandatory Storefront API proxying, which §8 explains.
2. The context
One rung up. app/lib/context.ts, abridged only where the skeleton comments:
import {createHydrogenContext} from '@shopify/hydrogen';
import {AppSession} from '~/lib/session';
import {CART_QUERY_FRAGMENT} from '~/lib/fragments';
export async function createHydrogenRouterContext(
request: Request, env: Env, executionContext: ExecutionContext,
) {
const waitUntil = executionContext.waitUntil.bind(executionContext);
const [cache, session] = await Promise.all([
caches.open('hydrogen'), // the worker cache — see §5
AppSession.init(request, [env.SESSION_SECRET]),
]);
return createHydrogenContext({
env, request, cache, waitUntil, session,
i18n: {language: 'EN', country: 'US'}, // or derive from the URL/cookie
cart: {queryFragment: CART_QUERY_FRAGMENT}, // your cart shape, typed end to end
});
}waitUntil is why background revalidation works. A worker is killed once it returns a response; waitUntil keeps it alive for a promise that outlives the response. Every stale-while-revalidate refresh in §5 rides on it.
What comes back:
| Property | What it is |
|---|---|
storefront | Typed Storefront API client. .query(), .mutate(), .CacheLong() etc. |
cart | Cart handler — get, addLines, updateLines, setCartId… |
customerAccount | OAuth client for the Customer Account API |
session | Your cookie session instance |
env | Typed environment variables |
cache · waitUntil | Passed through, so you can use them directly |
i18n | {country, language}, injected into queries automatically |
The session is a plain React Router cookie session that you own — createCookieSessionStorage, a class in app/lib/session.ts, swappable for anything implementing HydrogenSession. The one addition is isPending, set when anything writes to the session, which is what server.ts checks before committing — Sessions and Tokens · Cookies.
3. A route
export async function loader({context, params}: Route.LoaderArgs) {
const {product} = await context.storefront.query(PRODUCT_QUERY, {
variables: {handle: params.handle},
});
if (!product?.id) throw new Response(null, {status: 404});
return {product};
}That’s the whole integration. Routing, useLoaderData, error boundaries, nested layouts and revalidation are React Router’s and behave exactly as documented there.
i18n is injected, not passed. If the query declares $country or $language and you didn’t supply them, the client fills them from context.i18n:
query Product($country: CountryCode, $language: LanguageCode, $handle: String!)
@inContext(country: $country, language: $language) {
product(handle: $handle) { title }
}@inContext is a Storefront API directive: it localises prices, currency and translated content for that request. Declaring the variables is the opt-in; forget them and you silently serve one market’s prices to everyone.
4. Typed queries
Queries are template literals tagged with a #graphql comment and as const:
const PRODUCT_QUERY = `#graphql
query Product($handle: String!) {
product(handle: $handle) { id title }
}
` as const;The as const is load-bearing. Codegen writes a generated map from the exact query string to its return and variable types, so storefront.query(PRODUCT_QUERY, …) resolves the response type by literal-type lookup — no generic parameter, no manual interface, and a typo in a field name fails typecheck rather than returning undefined at runtime.
app/routes/products.$handle.tsx query string, `as const`
↓ shopify hydrogen codegen (--codegen flag on dev/build runs it in watch mode)
storefrontapi.generated.d.ts '#graphql query Product…': {return: …, variables: …}
↓
storefront.query(PRODUCT_QUERY) return type known, variables checked
.graphqlrc.ts defines two projects — the Storefront API schema for everything in app/, and the Customer Account API schema for app/graphql/customer-account/. Add a third for a CMS or the Admin API and its queries get the same treatment.
5. Caching
The part that is genuinely Hydrogen’s rather than React Router’s, and the one most worth understanding, because it’s where a headless storefront’s time to first byte is won or lost — Time to First Byte.
Two caches, doing different jobs:
BROWSER ──► OXYGEN EDGE ──► WORKER ──► STOREFRONT API
full-page cache sub-request cache
caches rendered caches individual GraphQL
HTML by the responses, keyed on
response's query + variables, in the
Cache-Control worker `caches` instance
Sub-request cache. Every storefront.query() result is cached. Pass a strategy to change how long:
const {collection} = await storefront.query(COLLECTION_QUERY, {
variables: {handle},
cache: storefront.CacheLong(),
});| Strategy | Cache-Control emitted | Use for |
|---|---|---|
CacheShort() | public, max-age=1, stale-while-revalidate=9 | Anything price- or stock-sensitive |
CacheLong() | public, max-age=3600, stale-while-revalidate=82800 | Menus, policies, static content |
CacheNone() | no-store | Customer data, anything per-visitor |
CacheCustom({…}) | Yours | mode, maxAge, staleWhileRevalidate, sMaxAge, staleIfError |
| (default) | public, max-age=1, stale-while-revalidate=86399 | What you get by not passing anything |
Mutations are never cached — storefront.mutate() bypasses the cache instance entirely.
stale-while-revalidate is the whole design, and the default proves it: fresh for one second, then stale-but-served for very nearly a day. Almost every request is a cache hit, and almost none serve data older than the time it takes to refetch in the background.
CacheShort() max-age=1 · stale-while-revalidate=9
t = 0.0s MISS → Storefront API, ~180ms, response stored
t = 0.4s HIT fresh
t = 3.0s HIT stale — served instantly, refetch runs in background ← the trick
t = 3.2s background refetch lands, entry replaced
t = 12.0s MISS past 1 + 9 = 10s, nothing to serve, blocking fetch
Notice row three: the visitor at t=3.0s pays nothing for the refresh. That only works because waitUntil keeps the worker alive past the response — see §2. Compare HTTP Caching, where the same directive governs shared caches rather than an in-worker one.
Full-page cache. Oxygen caches rendered HTML at the edge according to the response’s Cache-Control header, so a page that never varies by visitor can be served without invoking the worker at all — CDN Caching. The corollary is the failure mode: any route that renders customer-specific data must opt out, or one customer’s page is served to the next visitor.
export const headers: HeadersFunction = () => ({
'Cache-Control': 'no-store', // or 'private, max-age=60'
});Third-party APIs get the same machinery via createWithCache — a CMS, a reviews service, a PIM (product information management system):
const withCache = createWithCache({cache, waitUntil, request});
const {data} = await withCache.fetch('https://my-cms.com/api', {
method: 'POST', body: query,
}, {
cacheStrategy: CacheLong(),
shouldCacheResponse: (result) => !result?.errors, // never cache a failure
displayName: 'My CMS query', // labels it in the profiler
});shouldCacheResponse is the one to get right: without it an upstream error gets cached with the same generous stale window as a success, and a five-second outage becomes a day-long one.
The subrequest profiler in dev lists every cached and uncached call for a page with its timing and cache status — displayName is what makes third-party entries legible there.
6. Cart
Server-driven, and deliberately so. Cart state lives in Shopify; the cart id lives in a cookie; mutations are form posts to a route action. The cart therefore works before client JavaScript has loaded or when it fails to — Progressive Enhancement.
The flow:
<CartForm action={CartForm.ACTIONS.LinesAdd} inputs={{lines}}>
↓ native form POST to /cart
app/routes/cart.tsx → action()
↓ CartForm.getFormInput(formData) → {action, inputs}
↓ switch → context.cart.addLines(inputs.lines)
↓ Storefront API cartLinesAdd mutation
↓ cart.setCartId(result.cart.id) → Set-Cookie headers
↓ React Router revalidates every loader on the page
root loader's cart.get() re-runs, header count updates
The action is a switch over CartForm.ACTIONS:
export async function action({request, context}: Route.ActionArgs) {
const {cart} = context;
const {action, inputs} = CartForm.getFormInput(await request.formData());
let result: CartQueryDataReturn;
switch (action) {
case CartForm.ACTIONS.LinesAdd:
result = await cart.addLines(inputs.lines);
break;
case CartForm.ACTIONS.LinesUpdate:
result = await cart.updateLines(inputs.lines);
break;
case CartForm.ACTIONS.DiscountCodesUpdate:
result = await cart.updateDiscountCodes(discountCodes);
break;
// GiftCardCodesAdd, BuyerIdentityUpdate, LinesRemove, …
default:
throw new Error(`${action} cart action is not defined`);
}
const headers = result?.cart?.id ? cart.setCartId(result.cart.id) : new Headers();
return data({cart: result.cart, errors: result.errors, warnings: result.warnings},
{status: 200, headers});
}The cookie is written on every mutation, because the first addLines on a new visit creates the cart and there was no id until the response came back.
useOptimisticCart covers the round trip. It reads React Router’s in-flight fetchers and applies the pending mutation to the cart you already have, so quantity changes and removals render immediately and reconcile when the action returns:
const cart = useOptimisticCart(originalCart);The plain form still works with JavaScript disabled; the optimism is an enhancement layered on top, which is the same shape as everything else in this framework.
Cart handler methods, since the list isn’t obvious: get, getCartId, setCartId, create, addLines, updateLines, removeLines, updateDiscountCodes, addGiftCardCodes, removeGiftCardCodes, updateBuyerIdentity, updateNote, updateAttributes, setMetafields, deleteMetafield, updateSelectedDeliveryOption, replaceDeliveryAddresses.
7. Customer accounts
Login is OAuth against the Customer Account API, not a password form you own. Three routes, each a one-liner, because the client does the work:
// account_.login.tsx → redirects to Shopify's hosted login
export async function loader({context}: Route.LoaderArgs) {
return context.customerAccount.login({countryCode: context.storefront.i18n.country});
}
// account_.authorize.tsx → the callback. exchanges code for tokens, stores in session
export async function loader({context}: Route.LoaderArgs) {
return context.customerAccount.authorize();
}
// account_.logout.tsx → clears tokens, redirects to Shopify's logoutThen context.customerAccount.query(CUSTOMER_DETAILS_QUERY) for anything account-shaped — orders, addresses, profile. Tokens live in the session and are refreshed by the client; isLoggedIn() and handleAuthStatus() are the guards.
The catch is configuration, not code. Callback URIs have to be registered against the storefront in the Shopify admin, which is what shopify hydrogen customer-account-push does for a local development URL. Working on this offline or on an unregistered host will fail at the redirect, not at build time.
Order and customer queries use the Customer Account API schema, not the Storefront API’s — a separate schema, separate generated types, separate codegen project. Queries go in app/graphql/customer-account/.
8. Analytics and consent
Analytics.Provider wraps the app in root.tsx and page-level components emit events:
<Analytics.Provider cart={data.cart} shop={data.shop} consent={data.consent}>
<PageLayout {...data}><Outlet /></PageLayout>
</Analytics.Provider><Analytics.ProductView data={{products: [{id, title, price, vendor, variantId, quantity: 1}]}} />PageView, ProductView, CollectionView, CartView, SearchView and CartUpdate ship as components; useAnalytics() subscribes your own handlers to the same stream, which is how a GA4 or PostHog integration attaches without a second event layer.
Consent is wired in, not bolted on. The provider takes a consent object built in the root loader from PUBLIC_CHECKOUT_DOMAIN and the storefront token, and events are gated on Shopify’s Customer Privacy API rather than firing regardless — Consent Management.
2026.4 changed the mechanism underneath. The Storefront API proxy became mandatory: requests to the Storefront API URL now go through your domain, and Hydrogen’s request handler forwards them (storefront.isStorefrontApiUrl → storefront.forward). Consent state moved with it, from the legacy _tracking_consent JavaScript cookie to server-set cookies via that proxy — first-party, Set-Cookie rather than document.cookie, and so not subject to the seven-day cap browsers apply to script-written cookies. proxyStandardRoutes was removed as an option; a load context without a storefront instance now throws rather than warns — Browser Privacy Restrictions · Cookies.
9. Products and variants
A product with 3 options across 40 variants can’t have every combination queried up front, but the UI still needs to know which combinations exist and which are in stock — greyed-out swatches, without a round trip per click.
Shopify’s answer is to send that combination matrix as a compressed encoding, decoded on the client:
QUERY RESPONSE
encodedVariantExistence → which option combinations are real products
encodedVariantAvailability → which of those are available for sale
options { optionValues { … } } → the option names and values
selectedOrFirstAvailableVariant → what to render right now
adjacentVariants → one hop away in any single option
The helpers do the decoding: getProductOptions() builds the option array with each value already marked existent/available, decodeEncodedVariant and isOptionValueCombinationInEncodedVariant are the primitives underneath.
The client-side pieces that make swapping instant:
useOptimisticVariant(current, getAdjacentAndFirstAvailableVariants(product))— because adjacent variants were already fetched, choosing an option renders the new price and image immediately while the loader catches upuseSelectedOptionInUrlParam(selectedOptions)— writes the selection into the URL without navigating, so the variant is shareable and back/forward workgetSelectedProductOptions(request)— reads them back out, server-side, for the initial query
VariantSelector still exists and still works, but the skeleton no longer uses it; the option-array API above replaced it.
10. The rest of the surface
Everything exported by @shopify/hydrogen, grouped. Most of the display components are re-exports from @shopify/hydrogen-react, which is also the way to use them outside Hydrogen.
| Group | Exports |
|---|---|
| Context and server | createHydrogenContext · createRequestHandler · hydrogenRoutes · hydrogenPreset · storefrontRedirect |
| Cache | CacheNone · CacheShort · CacheLong · CacheCustom · createWithCache · InMemoryCache · generateCacheControlHeader |
| Cart | CartForm · useOptimisticCart · createCartHandler · cartLinesAddDefault and one …Default per mutation, for overriding a single operation |
| Customer | createCustomerAccountClient · useCustomerPrivacy |
| Analytics | Analytics.* · useAnalytics · getShopAnalytics · sendShopifyAnalytics · useShopifyCookies |
| Product | getProductOptions · getAdjacentAndFirstAvailableVariants · getSelectedProductOptions · useOptimisticVariant · useSelectedOptionInUrlParam · decodeEncodedVariant · VariantSelector |
| Display | Image · Money · useMoney · Video · ExternalVideo · ModelViewer · MediaFile · RichText · ShopPayButton |
| Lists | Pagination · getPaginationVariables · flattenConnection |
| SEO | getSeoMeta · Seo · getSitemap · getSitemapIndex |
| Security | createContentSecurityPolicy · NonceProvider · useNonce · Script |
| Utilities | parseGid · parseMetafield · graphiqlLoader · changelogHandler |
Three worth singling out:
Imageemits thesrcsetand sizes for Shopify’s CDN transformations rather than making you build URLs — Image OptimisationMoney/useMoneyformat from the API’s{amount, currencyCode}shape in the request’s locale. Currency subunits and locale conventions are exactly the thing hand-rolled formatting gets wrong — Revenue MetricsuseNonce+createContentSecurityPolicythread a per-request nonce through<Scripts nonce={nonce} />, which is what lets a strict content security policy — the header naming which script sources a page may execute — ship withoutunsafe-inline. It’s also whyassetsInlineLimit: 0is set in the Vite config — Content Security Policy
Pagination and getPaginationVariables implement cursor pagination over Storefront API connections, which is the only sane way to page a collection of unknown length.
11. Oxygen
Shopify’s hosting for Hydrogen: a V8 isolate at the edge, same shape as Cloudflare Workers. No Node APIs, no filesystem, no process that outlives the request.
Environment variables — the full HydrogenEnv interface:
SESSION_SECRET signs the session cookie
PUBLIC_STORE_DOMAIN my-shop.myshopify.com
PUBLIC_STOREFRONT_API_TOKEN public token, safe in the browser
PRIVATE_STOREFRONT_API_TOKEN server-only, higher rate limits
PUBLIC_STOREFRONT_ID identifies the storefront to analytics
PUBLIC_CHECKOUT_DOMAIN the domain checkout and consent run on
PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID OAuth client
PUBLIC_CUSTOMER_ACCOUNT_API_URL OAuth endpoint
SHOP_ID
shopify hydrogen env pull writes them into a local .env; env push and env list work the other way. Locally only SESSION_SECRET is required, because MiniOxygen can run against a mock shop.
Deployment. npx shopify hydrogen deploy builds, uploads and returns a unique preview URL. Every deployment is an immutable snapshot — changing an environment variable requires a redeploy, not a restart. The GitHub integration is the recommended path: push or merge, get a deployment, with previews per branch. Production and custom environments support rollback; the retention policy is a minimum of six months, with the ten most recent deployments per environment kept regardless of age, and runtime logs for up to one month.
The CLI — the commands actually used:
| Command | |
|---|---|
shopify hydrogen init | New storefront from the skeleton |
shopify hydrogen dev --codegen | MiniOxygen locally, types regenerated on save |
shopify hydrogen build --codegen | Production build |
shopify hydrogen preview --build | Serve the production build locally |
shopify hydrogen deploy | Build and deploy to Oxygen |
shopify hydrogen link / list / unlink | Connect the local project to a remote storefront |
shopify hydrogen env pull / push / list | Environment variables |
shopify hydrogen codegen | GraphQL types, one-shot |
shopify hydrogen upgrade | Guided version bump |
shopify hydrogen generate route | Scaffold a standard route from the template |
shopify hydrogen customer-account-push | Register account callback URIs for local dev |
shopify hydrogen shortcut | Installs h2 as a global alias |
Nothing ties the framework to Oxygen except the deployment target. It’s a worker; anywhere that runs one will run it. What you lose off-platform is the full-page edge cache, the deployment model and the first-party proxy domain — which is most of the reason to be there.
Project structure
server.ts the fetch handler — §1
vite.config.ts hydrogen() · oxygen() · reactRouter() plugins
react-router.config.ts hydrogenPreset()
.graphqlrc.ts two codegen projects: storefront, customer-account
env.d.ts type references, incl. oxygen-workers-types
storefrontapi.generated.d.ts generated — do not edit
customer-accountapi.generated.d.ts generated — do not edit
app/
root.tsx Layout, Analytics.Provider, error boundary
entry.server.tsx renderToReadableStream + CSP nonce
entry.client.tsx hydration
routes.ts hydrogenRoutes([...await flatRoutes()])
routes/ flat-file routing: products.$handle.tsx, cart.tsx,
collections.$handle.tsx, account_.login.tsx,
[sitemap.xml].tsx, search.tsx, $.tsx …
lib/
context.ts createHydrogenRouterContext — §2
session.ts AppSession, your cookie session
fragments.ts CART_QUERY_FRAGMENT, HEADER_QUERY, FOOTER_QUERY
variants.ts variant URL helpers
graphql/customer-account/ queries against the other schema
components/ CartMain, ProductForm, PaginatedResourceSection …
styles/
app/routes.ts is worth a second look. File-based routing is opt-in now: flatRoutes() produces the config from the filesystem, hydrogenRoutes() wraps it to add Hydrogen’s internal routes, and you can append manual definitions to the same array.
Route names encode nesting. account.orders._index.tsx nests under account.tsx; the trailing underscore in account_.login.tsx opts out of that layout, so login doesn’t render inside the logged-in account shell.
Required versus optional
| Required? | Notes | |
|---|---|---|
| Shopify store + Storefront API token | Yes | The whole point |
| React Router 7 | Yes | Peer dependency ^7.12.0; skeleton pins 7.16.0 |
| Vite | Yes | The build is Vite; skeleton on Vite 8 |
| Node 22 or 24 | Yes | For the toolchain. The runtime is a worker, not Node |
| Oxygen | No | Any worker runtime. You lose the edge page cache and the deploy model |
createHydrogenContext | No, in principle | You can assemble storefront/cart/customerAccount by hand. Since 2026.4 the request handler does require a storefront in the context |
| Codegen | No | Queries are plain strings without it. You lose every type |
| Cart handler | No | Call the cart mutations yourself, or override one with cartLinesAddDefault and keep the rest |
PRIVATE_STOREFRONT_API_TOKEN | No | Public token works; private gets higher rate limits — Rate Limiting |
| Customer Account API | No | Only if the storefront has accounts |
| Analytics provider | No | Drop it and wire your own — but consent handling goes with it |
| The Hydrogen toolkit | No | Different product. See below |
A complete route, top to bottom
Product page — loader, query, variant handling, analytics. Trimmed of fragments only.
import {useLoaderData} from 'react-router';
import type {Route} from './+types/products.$handle';
import {
getSelectedProductOptions, getProductOptions,
getAdjacentAndFirstAvailableVariants, useOptimisticVariant,
useSelectedOptionInUrlParam, Analytics,
} from '@shopify/hydrogen';
export const meta: Route.MetaFunction = ({data}) => [
{title: `Hydrogen | ${data?.product.title ?? ''}`},
{rel: 'canonical', href: `/products/${data?.product.handle}`},
];
export async function loader(args: Route.LoaderArgs) {
const deferredData = loadDeferredData(args); // not awaited — streams in later
const criticalData = await loadCriticalData(args); // awaited — blocks the response
return {...deferredData, ...criticalData};
}
async function loadCriticalData({context, params, request}: Route.LoaderArgs) {
const [{product}] = await Promise.all([
context.storefront.query(PRODUCT_QUERY, {
variables: {
handle: params.handle,
selectedOptions: getSelectedProductOptions(request), // from ?Color=Blue
},
}),
// other critical queries here, so they run in parallel
]);
if (!product?.id) throw new Response(null, {status: 404});
return {product};
}
function loadDeferredData({context, params}: Route.LoaderArgs) {
return {}; // reviews, recommendations — below the fold, must not throw
}
export default function Product() {
const {product} = useLoaderData<typeof loader>();
const selectedVariant = useOptimisticVariant(
product.selectedOrFirstAvailableVariant,
getAdjacentAndFirstAvailableVariants(product),
);
useSelectedOptionInUrlParam(selectedVariant.selectedOptions);
const productOptions = getProductOptions({
...product,
selectedOrFirstAvailableVariant: selectedVariant,
});
return (
<div className="product">
<ProductImage image={selectedVariant?.image} />
<h1>{product.title}</h1>
<ProductPrice price={selectedVariant?.price}
compareAtPrice={selectedVariant?.compareAtPrice} />
<ProductForm productOptions={productOptions} selectedVariant={selectedVariant} />
<div dangerouslySetInnerHTML={{__html: product.descriptionHtml}} />
<Analytics.ProductView data={{products: [{
id: product.id, title: product.title,
price: selectedVariant?.price.amount || '0', vendor: product.vendor,
variantId: selectedVariant?.id || '', variantTitle: selectedVariant?.title || '',
quantity: 1,
}]}} />
</div>
);
}
const PRODUCT_QUERY = `#graphql
query Product($country: CountryCode, $handle: String!, $language: LanguageCode,
$selectedOptions: [SelectedOptionInput!]!)
@inContext(country: $country, language: $language) {
product(handle: $handle) { ...Product }
}
${PRODUCT_FRAGMENT}
` as const;The critical/deferred split is the pattern to take from this. Everything awaited delays the first byte; everything returned as an unawaited promise streams in after the shell — Streaming Responses · Largest Contentful Paint. The skeleton writes it as two functions on every route to make the decision explicit rather than accidental.
Which Hydrogen
The toolkit announced in June 2026 unbundles commerce from the framework: a typed Storefront client, cart, money, markets, Shop Pay and consent-aware analytics as plain primitives with framework bindings on top, runnable under Next.js, SvelteKit, Astro or anything else. Shopify’s framing:
“A framework is a strong opinion about everything: routing, data loading, rendering, deployment. That opinion is a gift when it matches how you want to work, and a tax when it doesn’t.”
The test they applied: could anyone else build this? Routing — yes, six teams already have. A typed client that tracks the Storefront API schema, cart primitives matching Shopify’s actual cart semantics, Shop Pay — no. That’s where the line got drawn.
NEW BUILD, want it to work today, no strong framework preference
→ this framework. stable, documented, supported, one decision
NEW BUILD, committed to Next.js or another framework
→ the toolkit, accepting developer-preview churn
→ or the Storefront API directly, and adopt the toolkit when it stabilises
EXISTING HYDROGEN STOREFRONT
→ stay. it keeps working, it's still being released against the
current API version, and there is no migration path to plan yet
EVALUATING HEADLESS AT ALL
→ the pivot makes the decision smaller either way — you're no longer
choosing a framework and a commerce layer in one move
That last row is the one worth sitting with — Headless Architecture · Composable Commerce.
The strategic read: unbundling removes the main objection to Hydrogen — that getting Shopify’s commerce advantages meant adopting Shopify’s entire framework opinion, which is why so many teams built on Next.js and wired the Storefront API up themselves. It also concedes that the framework layer was never Shopify’s advantage, a point the market had already made by doing exactly that.
[CHECK: version-dependent throughout. 2026.4.x was current when checked, and Hydrogen’s calendar versioning ties the release to the Storefront API version — 2026.4 → API 2026-04 — so a release is also an API upgrade with its own breaking changes. Re-verify the export surface, cache defaults and env var names against the installed version before writing code. The toolkit’s status is the fastest-moving claim here.]
Related
- Shopify — the platform this is one surface of
- Reference - React Router — the routing and data half of this framework, deliberately not repeated above
- Reference - Remix — the name React Router used to carry, and the unrelated project carrying it now
- Headless Architecture — the decision this sits inside
- Composable Commerce — the broader pattern, and the integration cost it implies
- Edge Computing — the runtime model Oxygen is an instance of
- HTTP Caching — the directives §5 leans on entirely
- Progressive Enhancement — what the server-driven cart buys
- Rendering Strategies — where streaming server rendering with hydration sits among the options