Hydrogen 2026.10 Migration: Framework to Library

The Hydrogen 2026.10 migration moves a storefront from a framework that owned the request to a library you wire into React Router yourself. Routes and pages mostly survive. The server entry, caching, cart UI, analytics and CSP do not. Legacy storefronts keep working, so schedule this deliberately and pin your version first.

Will npm update move me to the new Hydrogen by accident?

It can. As of November 2026, npm’s latest tag for @shopify/hydrogen is 2026.10.2, described as a “framework-agnostic Shopify storefront SDK”. The last classic release is 2026.4.8, under the 2026-04 tag. Hydrogen uses calendar versions inside semver, so ^2026.4.0 resolves to 2026.10.2 and an unattended update can swap your framework for a library with a different API.

Pin it today, whatever you decide about the rest. Use the exact version you already run if you are not ready for 2026.4.8, whose peer range wants React Router ~7.16.0:

npm view @shopify/hydrogen dist-tags
npm install --save-exact @shopify/[email protected]

Give your dependency bot the same rule, or it will open the 2026.10 bump as a routine update.

Do I have to migrate now?

No. Shopify’s 2026-10 changelog entry says existing storefronts keep working and no action is required today. Neither that entry nor the legacy Hydrogen docs gives an end-of-support date as of November 2026, though the docs do recommend migrating.

There is one clock you can read for yourself. The 2026.4.8 package targets Storefront API 2026-04, the library targets 2026-10, and Shopify’s versioning policy supports each stable version for a minimum of 12 months. After that, requests fall forward to the oldest accessible version, so your old client keeps getting answers from a newer schema than the one it shipped with.

What changes in a Hydrogen 2026.10 migration?

Shopify describes the result as “a library that you add to your framework, instead of a framework of its own”. Your app now owns the server entry, the router and the build. The migration guide lists what goes: the hydrogen() Vite plugin, shopify hydrogen dev and build, createHydrogenContext() and context.storefront. Scripts become plain Vite and React Router, with @shopify/cli kept for shopify hydrogen deploy, link and env pull.

Remix and Hydrogen are separate axes here. A store already on React Router 7 can still be on the classic framework, and the tell is the package version: anything below 2026.10 has not been migrated. Stores still on @remix-run/* do both moves.

Most of the conversion lands in one root middleware. This is the shape from Shopify’s packaged migration skill, and the ~/lib modules are yours to write from the guide:

import {handleShopifyRedirects, handleShopifyRoutes} from '@shopify/hydrogen';
import {cartHandlers} from '~/lib/cart-handlers';
import {routeTemplates} from '~/lib/route-templates';
import {createEphemeralSessionManager} from '~/lib/session';
import {
  createRequestStorefrontClient, envContext, storefrontClientContext, storefrontRequestContext,
} from '~/lib/storefront';
import type {Route} from './+types/root';

export const middleware: Route.MiddlewareFunction[] = [
  async ({context, request}, next) => {
    const env = context.get(envContext);
    const storefrontClient = createRequestStorefrontClient(request, env, context.cache, context.waitUntil);
    const requestContext = storefrontClient.requestContext;
    const sessionManager = createEphemeralSessionManager(request);

    const shopifyRoute = handleShopifyRoutes({
      request, requestContext, sessionManager, storefrontClient, routeTemplates, handlers: [cartHandlers],
    });
    if (shopifyRoute) return shopifyRoute;

    context.set(storefrontClientContext, storefrontClient);
    context.set(storefrontRequestContext, requestContext);

    const response = await next();

    const sessionHeaders = new Headers((await sessionManager.commit?.()) ?? undefined);
    for (const [name, value] of sessionHeaders) response.headers.append(name, value);
    if (response.status === 404) {
      const redirect = await handleShopifyRedirects({request, storefrontClient, routeTemplates});
      if (redirect) return redirect;
    }
    requestContext.applyResponseHeaders(response.headers);
    return response;
  },
];

handleShopifyRoutes is not awaited before the check because it decides synchronously and returns a Promise<Response> or null. Reuse the client’s requestContext as well, since a second one makes it throw.

How do loaders change?

Every data call changes shape:

import {Cache} from '@shopify/hydrogen';

// 2026.4
const {product} = await context.storefront.query(PRODUCT_QUERY, {
  variables: {handle, country: context.storefront.i18n.country},
  cache: context.storefront.CacheLong(),
});

// 2026.10
const storefront = context.get(storefrontClientContext);
const {data, errors} = await storefront.graphql(PRODUCT_QUERY, {
  variables: {handle},
  cache: Cache.long(),
});
if (errors) console.error('Storefront API returned errors', errors);
const product = data?.product;

Three behaviours hide in that diff. A 200 response with GraphQL errors does not throw, while transport failures raise StorefrontApiError. $country and $language are filled from the request context whenever the query declares them, and that context overrides anything you pass.

The third raises no error at all. Classic’s implicit default caching is gone, and a client configured with cache caches nothing until a query asks. Any loader that relied on the default now hits the API on every request, so walk through each one with our notes on classic sub-request caching open. If the types then fail with cache does not exist in type 'MergedOptions', your client alias dropped the option; the skill shows the {cache?: CachingStrategy} generic that restores it.

What breaks while the build stays green?

npm run typecheck finds missed imports. Everything below compiles.

Deploys and routing

Set buildDirectory: 'dist' in react-router.config.ts. With the default build, the guide warns, “the build succeeds but the deploy uploads nothing”.

Keep a catch-all route. Middleware runs only for matched routes, so without one an unmatched path never reaches handleShopifyRoutes or the 404 branch, and Shopify’s redirects stop firing. The template has route("*", "routes/catchall.tsx") for this reason. Delete your old route files for /api/cart, /checkout, cart permalinks and /account/login, since the handlers own those paths now.

Drop applyResponseHeaders and Shopify’s cookies and cache headers disappear from your pages. Nothing errors.

Cart

The drawer will render empty while adds succeed. The cart is now a client store, so a carried-over <Await resolve={cart}> resolves to undefined. Read from useCart((state) => state.data), and seed initialData from cartHandlers.get() in the root loader. Any logic that opens the drawer from useFetchers() also dies quietly, because useCartForm() submits around React Router’s fetchers.

Analytics.Provider, useAnalytics() and getShopAnalytics() are removed. You render ShopifyScripts and publish to window.Shopify.analytics yourself. Page views are the easy part. Product views, cart views and cart changes are what get lost, and destinations only receive events once Customer Privacy allows analytics, so port consent in the same change.

CSP and login

createContentSecurityPolicy, NonceProvider and useNonce are gone from the package, and the policy becomes your code. Our classic CSP post covers how the old helper’s defaults were extended, which is the list to review when you port it. Drop the nonce and the page still renders while hydration is dead, taking the cart and variant picker with it. Check the console.

Customer login needs a cookie-backed session, because the OAuth state written on /account/login is read back on a later request and a per-request Map cannot hold it. Link to it with <a> rather than <Link>, and test it through npm run dev:https.

What do I have to rewrite rather than port?

getSitemap, getSitemapIndex and getSeoMeta have no replacement, so the sitemap fixes we wrote up become your own queries and XML responses. <Image>, <MediaFile>, <Money> and <Pagination> are out too. Use formatMoney() for prices.

On pagination the sources disagree. The guide says to write your own, but the packaged skill says use createCollectionStore, and the official template follows the skill with CollectionProvider. Follow the template.

Migrate features in Shopify’s order, markets then customer accounts then analytics, since each consumes output from the one before. Then run react-router typegen && tsc && hydrogen gql check --fail-on-warn. That last command catches invalid fields tsc misses.

When should you migrate?

Pin now, and migrate when you have another reason to touch the storefront: a cart rebuild, a move off Oxygen, or a Next.js app that wants commerce inside it. The first already means rewriting what the migration replaces, and the library is what makes the other two possible without replatforming. I would hold off on a stable store with no planned work and a peak trading period ahead, because the failures above are silent and a peak is an expensive time to discover missing cart behaviour or analytics. When you do go, give the migration its own release and compare product view, cart view and cart change events before and after.

We read the 2026.10.2 package, its skills and the React Router template for this write-up; we have not run the migration or these samples, so check every signature against your installed .d.mts files.

Whoooop builds and fixes Hydrogen storefronts. If you want a second pair of eyes on a move to 2026.10, our Shopify development team can help.

Need this built properly?

Get in touch