Hydrogen 2 is Shopify's opinionated headless storefront framework built on top of Remix. Unlike Hydrogen 1 — which was a bespoke React library — Hydrogen 2 is a thin layer on top of standard Remix conventions. You get file-based routing, server-side loaders, nested layouts, and progressive enhancement for free. On top of that Hydrogen adds a GraphQL client pre-configured for the Storefront API, a cart management system, Shopify-optimised caching helpers, and deployment to Oxygen, Shopify's own edge hosting. This tutorial takes you from zero to a working storefront with product listing, PDP, and cart functionality.
What you'll build
A minimal but complete Hydrogen 2 storefront with: a homepage that lists collections, a collection page with paginated products, a product detail page (PDP) with a variant selector and add-to-cart button, a cart drawer, and a checkout redirect. All data is fetched from the Shopify Storefront API using the built-in storefront client.
Before you start
- Node.js 18 or later
- Shopify CLI 3.x (npm install -g @shopify/cli)
- A Shopify store (development store is fine) with at least a few products and one collection
- Basic familiarity with React and TypeScript
Create a new Hydrogen project
Shopify CLI includes a Hydrogen scaffold command. Run the init command and choose the 'Hello World' template to get the minimal starter, or 'Demo Store' for a full-featured reference. For this tutorial, use 'Hello World' so you can build each piece from scratch and understand what each layer does.
During the init flow, the CLI will ask you to link the project to a Shopify store. This sets up an .env file with your SHOPIFY_STORE_DOMAIN and PUBLIC_STOREFRONT_API_TOKEN automatically. Choose TypeScript when prompted — Hydrogen ships comprehensive types for the Storefront API.
npm create @shopify/hydrogen@latest
# Or with the Shopify CLI:
shopify hydrogen init
# Answer the prompts:
# Project name: my-storefront
# Template: Hello World
# Language: TypeScript
# Link to store: yes (select your dev store)Expected outcome: A my-storefront/ directory with a working Remix-based Hydrogen app. The .env file contains valid SHOPIFY_STORE_DOMAIN and PUBLIC_STOREFRONT_API_TOKEN values pointing to your development store.
Understand the storefront client and caching
Hydrogen injects a storefront client into every Remix loader via the request context. Access it by calling createHydrogenContext in server.ts, which returns a storefront object. In any loader, you call context.storefront.query(GRAPHQL_STRING, { variables }) to fetch Storefront API data. The result is typed based on your query, assuming you're using @shopify/hydrogen-codegen.
Caching is handled with Hydrogen's CacheLong, CacheShort, CacheNone, and CacheCustom strategies. Pass a cache option to storefront.query(): { cache: storefront.CacheLong() } caches the response at the CDN edge for one hour. Product data that changes infrequently should use CacheLong; cart data should never be cached (CacheNone). On Oxygen, these caches live at Cloudflare's edge, meaning sub-5ms response times for cached routes.
// app/routes/_index.tsx
import { json } from '@shopify/remix-oxygen';
import type { LoaderFunctionArgs } from '@shopify/remix-oxygen';
const COLLECTIONS_QUERY = `#graphql
query FeaturedCollections {
collections(first: 4, sortKey: UPDATED_AT, reverse: true) {
nodes {
id
title
handle
image { url altText width height }
}
}
}
` as const;
export async function loader({ context }: LoaderFunctionArgs) {
const { storefront } = context;
const { collections } = await storefront.query(COLLECTIONS_QUERY, {
cache: storefront.CacheLong(),
});
return json({ collections: collections.nodes });
}Expected outcome: The homepage loader returns an array of collections from the Storefront API, cached at the edge for one hour.
Build the collection page with cursor pagination
Hydrogen's Storefront API client supports Relay-style cursor pagination. The getPaginationVariables() helper from @shopify/hydrogen parses the ?cursor= and ?direction= query params from the URL and returns the correct first/after/last/before variables for your GraphQL query. This means pagination works with a standard Remix Form or <Link> component pointing to ?cursor=xxx.
The Pagination component from @shopify/hydrogen renders previous/next navigation based on pageInfo.hasPreviousPage and pageInfo.hasNextPage. It handles the cursor logic so you don't have to build pagination UI from scratch.
// app/routes/collections.$handle.tsx
import { json } from '@shopify/remix-oxygen';
import { useLoaderData, Link } from '@remix-run/react';
import { getPaginationVariables, Pagination, Image, Money } from '@shopify/hydrogen';
import type { LoaderFunctionArgs } from '@shopify/remix-oxygen';
const COLLECTION_QUERY = `#graphql
query Collection($handle: String!, $first: Int, $last: Int, $after: String, $before: String) {
collection(handle: $handle) {
id title description
products(first: $first, last: $last, after: $after, before: $before) {
nodes {
id title handle
featuredImage { url altText }
priceRange { minVariantPrice { amount currencyCode } }
}
pageInfo { hasNextPage hasPreviousPage startCursor endCursor }
}
}
}
` as const;
export async function loader({ params, request, context }: LoaderFunctionArgs) {
const paginationVariables = getPaginationVariables(request, { pageBy: 12 });
const { collection } = await context.storefront.query(COLLECTION_QUERY, {
variables: { handle: params.handle!, ...paginationVariables },
cache: context.storefront.CacheShort(),
});
if (!collection) throw new Response('Collection not found', { status: 404 });
return json({ collection });
}
export default function CollectionPage() {
const { collection } = useLoaderData<typeof loader>();
return (
<div>
<h1>{collection.title}</h1>
<Pagination connection={collection.products}>
{({ nodes, NextLink, PreviousLink }) => (
<>
<PreviousLink>Load previous</PreviousLink>
<div className="grid">
{nodes.map((product) => (
<Link key={product.id} to={`/products/${product.handle}`}>
<Image data={product.featuredImage!} />
<h3>{product.title}</h3>
<Money data={product.priceRange.minVariantPrice} />
</Link>
))}
</div>
<NextLink>Load more</NextLink>
</>
)}
</Pagination>
</div>
);
}Expected outcome: Navigating to /collections/frontpage shows a 12-product grid with Load more and Load previous navigation.
Build the product detail page with a variant selector
The PDP is the most complex page in a Hydrogen storefront. You need to fetch the product, its variants, and the currently selected variant based on URL search params. Hydrogen's getSelectedProductOptions() helper parses the ?Color=Red&Size=Large style search params into ProductSelectedOptionInput[] for use with the selectedVariant: product.variantBySelectedOptions() query field.
The VariantSelector component from @shopify/hydrogen wraps your option UI and handles URL-based variant selection. Pair it with the AddToCartButton component to get a complete add-to-cart flow that uses Remix fetcher under the hood, keeping the page interactive without a full reload.
// app/routes/products.$handle.tsx (simplified)
import { VariantSelector, AddToCartButton, Money } from '@shopify/hydrogen';
import { getSelectedProductOptions } from '@shopify/hydrogen';
export async function loader({ params, request, context }: LoaderFunctionArgs) {
const selectedOptions = getSelectedProductOptions(request);
const { product } = await context.storefront.query(PRODUCT_QUERY, {
variables: { handle: params.handle!, selectedOptions },
cache: context.storefront.CacheShort(),
});
if (!product) throw new Response('Product not found', { status: 404 });
return json({ product });
}
export default function ProductPage() {
const { product } = useLoaderData<typeof loader>();
const selectedVariant = product.selectedVariant ?? product.variants.nodes[0];
return (
<div>
<h1>{product.title}</h1>
<Money data={selectedVariant.price} />
<VariantSelector handle={product.handle} options={product.options}>
{({ option }) => (
<div key={option.name}>
<label>{option.name}</label>
{option.values.map(({ value, isAvailable, to, isActive }) => (
<Link key={value} to={to} prefetch="intent"
style={{ fontWeight: isActive ? 'bold' : 'normal',
opacity: isAvailable ? 1 : 0.3 }}>
{value}
</Link>
))}
</div>
)}
</VariantSelector>
<AddToCartButton
disabled={!selectedVariant.availableForSale}
lines={[{ merchandiseId: selectedVariant.id, quantity: 1 }]}
>
{selectedVariant.availableForSale ? 'Add to cart' : 'Sold out'}
</AddToCartButton>
</div>
);
}Expected outcome: The PDP shows variant option selectors. Clicking an option updates the URL and re-runs the loader to fetch the matching variant's price and availability. The Add to cart button adds the selected variant to the cart.
Set up the cart with Hydrogen's cart handler
Hydrogen provides a createCartHandler() function in @shopify/hydrogen that abstracts the Storefront API cart mutations. You configure it once in server.ts and it exposes a cart object in context. The handler persists the cart ID in a cookie and manages creating, updating, and reading the cart across requests.
Create an api.cart.tsx route that handles cart actions (add lines, remove lines, update quantities) via Remix actions. The AddToCartButton and CartForm components from @shopify/hydrogen POST to this route automatically. In the loader, return the current cart for display. This design keeps cart state server-authoritative and avoids client-side state management.
// app/routes/api.cart.tsx
import { CartForm } from '@shopify/hydrogen';
import type { ActionFunctionArgs } from '@shopify/remix-oxygen';
export async function action({ request, context }: ActionFunctionArgs) {
const { cart } = context;
const formData = await request.formData();
const { action, inputs } = CartForm.getFormInput(formData);
let result;
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.LinesRemove:
result = await cart.removeLines(inputs.lineIds);
break;
default:
throw new Error(`Unexpected cart action: ${action}`);
}
const headers = cart.setCartId(result.cart.id);
return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json', ...Object.fromEntries(headers) },
});
}Expected outcome: Cart actions (add, update, remove) are handled server-side. The cart ID is persisted in a cookie. The AddToCartButton on the PDP successfully adds items to the cart without a full page reload.
Deploy to Oxygen
Oxygen is Shopify's edge hosting platform for Hydrogen storefronts. It runs on Cloudflare Workers globally and is free for Shopify merchants. Connect your GitHub repository in the Hydrogen section of your Shopify Admin (Online Store > Custom Storefronts), and every push to main triggers a deployment. Preview deployments are created for pull requests automatically.
Before connecting to GitHub, run shopify hydrogen deploy from the CLI at least once to create the Oxygen deployment target in your Partner account. This initializes the deployment configuration. After that, GitHub-connected deployments take over and CI deployments happen automatically on every push.
# One-time manual deploy to initialize the Oxygen target
shopify hydrogen deploy
# Check the deployment URL printed in the CLI output
# e.g. https://my-storefront.myshopify.io
# After connecting GitHub, push to deploy:
git push origin mainExpected outcome: Your storefront is live on an *.myshopify.io subdomain served from Cloudflare's edge network. Response times for cached collection pages should be under 50ms from major population centers.
What's next
This storefront is functional but minimal. The natural next steps are: adding SEO metadata using Hydrogen's getSeoMeta() helper, implementing a search page with predictive search using the Storefront API's predictiveSearch query, adding Customer account login via the new Customer Account API (customer-accountapi), and setting up analytics using Hydrogen's built-in Analytics component to push events to Shopify's own analytics or a third party.
