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 context injected into every loader and a cache in front of every API call. Learn context.storefront, context.cart and 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:

PropertyWhat it is
storefrontTyped Storefront API client. .query(), .mutate(), .CacheLong() etc.
cartCart handler — get, addLines, updateLines, setCartId…
customerAccountOAuth client for the Customer Account API
sessionYour cookie session instance
envTyped environment variables
cache · waitUntilPassed 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(),
});
StrategyCache-Control emittedUse for
CacheShort()public, max-age=1, stale-while-revalidate=9Anything price- or stock-sensitive
CacheLong()public, max-age=3600, stale-while-revalidate=82800Menus, policies, static content
CacheNone()no-storeCustomer data, anything per-visitor
CacheCustom({…})Yoursmode, maxAge, staleWhileRevalidate, sMaxAge, staleIfError
(default)public, max-age=1, stale-while-revalidate=86399What 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 logout

Then 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/.


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 up
  • useSelectedOptionInUrlParam(selectedOptions) — writes the selection into the URL without navigating, so the variant is shareable and back/forward work
  • getSelectedProductOptions(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.

GroupExports
Context and servercreateHydrogenContext · createRequestHandler · hydrogenRoutes · hydrogenPreset · storefrontRedirect
CacheCacheNone · CacheShort · CacheLong · CacheCustom · createWithCache · InMemoryCache · generateCacheControlHeader
CartCartForm · useOptimisticCart · createCartHandler · cartLinesAddDefault and one …Default per mutation, for overriding a single operation
CustomercreateCustomerAccountClient · useCustomerPrivacy
AnalyticsAnalytics.* · useAnalytics · getShopAnalytics · sendShopifyAnalytics · useShopifyCookies
ProductgetProductOptions · getAdjacentAndFirstAvailableVariants · getSelectedProductOptions · useOptimisticVariant · useSelectedOptionInUrlParam · decodeEncodedVariant · VariantSelector
DisplayImage · Money · useMoney · Video · ExternalVideo · ModelViewer · MediaFile · RichText · ShopPayButton
ListsPagination · getPaginationVariables · flattenConnection
SEOgetSeoMeta · Seo · getSitemap · getSitemapIndex
SecuritycreateContentSecurityPolicy · NonceProvider · useNonce · Script
UtilitiesparseGid · parseMetafield · graphiqlLoader · changelogHandler

Three worth singling out:

  • Image emits the srcset and sizes for Shopify’s CDN transformations rather than making you build URLs — Image Optimisation
  • Money / useMoney format 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 Metrics
  • useNonce + createContentSecurityPolicy thread 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 without unsafe-inline. It’s also why assetsInlineLimit: 0 is 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 initNew storefront from the skeleton
shopify hydrogen dev --codegenMiniOxygen locally, types regenerated on save
shopify hydrogen build --codegenProduction build
shopify hydrogen preview --buildServe the production build locally
shopify hydrogen deployBuild and deploy to Oxygen
shopify hydrogen link / list / unlinkConnect the local project to a remote storefront
shopify hydrogen env pull / push / listEnvironment variables
shopify hydrogen codegenGraphQL types, one-shot
shopify hydrogen upgradeGuided version bump
shopify hydrogen generate routeScaffold a standard route from the template
shopify hydrogen customer-account-pushRegister account callback URIs for local dev
shopify hydrogen shortcutInstalls 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 tokenYesThe whole point
React Router 7YesPeer dependency ^7.12.0; skeleton pins 7.16.0
ViteYesThe build is Vite; skeleton on Vite 8
Node 22 or 24YesFor the toolchain. The runtime is a worker, not Node
OxygenNoAny worker runtime. You lose the edge page cache and the deploy model
createHydrogenContextNo, in principleYou can assemble storefront/cart/customerAccount by hand. Since 2026.4 the request handler does require a storefront in the context
CodegenNoQueries are plain strings without it. You lose every type
Cart handlerNoCall the cart mutations yourself, or override one with cartLinesAddDefault and keep the rest
PRIVATE_STOREFRONT_API_TOKENNoPublic token works; private gets higher rate limits — Rate Limiting
Customer Account APINoOnly if the storefront has accounts
Analytics providerNoDrop it and wire your own — but consent handling goes with it
The Hydrogen toolkitNoDifferent 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.]