Back to resources

Tech Exploration: Building dynamic e-commerce storefronts with Shopify Hydrogen

Welcome to Tech Exploration, where Ketryon tests innovative tools to power modern solutions. In this edition, we dive into building headless e-commerce storefronts with Shopify Hydrogen.

Tech Exploration: Building dynamic e-commerce storefronts with Shopify Hydrogen

Image credit: Photo by @__matthoffman__ on Unsplash

Shopify HydrogenRemixHeadless CommerceE-commerce
By Kenny TranPublished on 4/8/2025Last updated on 4/8/2025

Introduction

At Ketryon, we’re passionate about tools that empower businesses to thrive in e-commerce. That's why we decided to explore Shopify Hydrogen, a React-based framework for building custom, headless Shopify storefronts. Unlike Liquid’s rigid templates, Hydrogen decouples the frontend for dynamic product pages, carts, and search functionality.

What Is Shopify Hydrogen?

Shopify Hydrogen is an open-source, React-based framework for building custom Shopify storefronts, designed for headless commerce. It uses Shopify’s Storefront API to fetch products, carts, and customer data, paired with Remix for server-side rendering (SSR) and routing. Hydrogen includes components (e.g., ShopPayButton, CartLineItem), hooks (e.g., useShopQuery, useCart), Tailwind CSS for styling, and Vite for fast development.

Key features:

  • Headless Architecture: Separates UI from backend for full design control.
  • Performance: SSR, React Server Components, and fast data loading ensure sub-second page loads.
  • Customization: Reusable components and APIs for unique designs.
  • Oxygen Hosting: Shopify’s global CDN for seamless deployment.
  • SEO Tools: Auto-generated sitemaps and metadata to boost organic traffic.

Hydrogen powers stores like Lady Gaga’s celebrity merch shop and Manors’ golf apparel storefront, delivering scalable, personalized experiences.

Why It’s Relevant

Shopify Hydrogen blends developer efficiency with business impact, redefining e-commerce.

  • For Businesses: It delivers fast, personalized storefronts with SEO.
  • For Developers: Components and hooks simplify data fetching, while Remix and Vite enable rapid iteration.
  • Industry Trend: Headless commerce is growing, with 2024 seeing brands prioritize speed and flexibility.

Our Test Drive

To explore Shopify Hydrogen’s versatility, we built a demo storefront for a mock sneaker store, showcasing three use cases: a product page, a cart page with add-to-cart functionality, and a search page with filtering.

Project Setup

We initialized a Hydrogen project using Remix’s App Router and set up Shopify integration:

  1. Environment Setup:
  • Created a Hydrogen app: npm create @shopify/hydrogen@latest -- --template hello-world.
  • Installed dependencies: npm i @shopify/hydrogen @shopify/remix-oxygen @shopify/hydrogen-ui vite tailwindcss.
  • Set up Shopify Storefront API token in .env:
PUBLIC_STOREFRONT_API_TOKEN=shpat_xxx
PUBLIC_SHOPIFY_DOMAIN=myshop.myshopify.com
  1. Hydrogen Configuration:
  • Used Remix for routing and SSR, with Vite for hot module replacement.
  • Linked to a Shopify store via the Hydrogen sales channel app.
  • Configured Tailwind in tailwind.config.js and app/tailwind.css:
@tailwind base;
@tailwind components;
@tailwind utilities;

Use Case 1: Product Page

We built a product page in app/routes/products.$handle.tsx, using useShopQuery to fetch data and ShopPayButton for payments, with fallback logic for images/variants:

import { defer } from "@shopify/remix-oxygen";
import { useLoaderData, Link } from "@remix-run/react";
import { ShopPayButton, Image } from "@shopify/hydrogen";
import { useState } from "react";

export async function loader({ params, context }) {
  const { handle } = params;
  const { storefront } = context;
  const { product } = await storefront.query(PRODUCT_QUERY, {
    variables: { handle },
  });
  return defer({ product, shop: context.shop });
}

export default function ProductPage() {
  const { product, shop } = useLoaderData();
  const [consent, setConsent] = useState(false);
  const variant = product.variants.nodes[0] || {};
  const image = product.images.nodes[0] || { url: "/placeholder.jpg" };
  const variantId = variant.id;

  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold">{product.title}</h1>
      <div className="grid grid-cols-2 gap-6 mt-6">
        <Image data={image} className="w-full" />
        <div>
          <p className="text-lg">{product.description}</p>
          <p className="text-2xl font-semibold mt-4">
            ${variant.price?.amount || "N/A"}
          </p>
          <label className="block mt-4">
            <input
              type="checkbox"
              checked={consent}
              onChange={(e) => setConsent(e.target.checked)}
            />
            I agree to the <Link to="/privacy">privacy policy</Link>.
          </label>
          {variantId && (
            <div className="mt-4">
              <ShopPayButton
                variantIds={[variantId]}
                storeDomain={shop.domain}
                disabled={!consent}
              />
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

const PRODUCT_QUERY = `#graphql
  query ($handle: String!) {
    product(handle: $handle) {
      id
      title
      description
      images(first: 1) {
        nodes {
          url
        }
      }
      variants(first: 1) {
        nodes {
          id
          price {
            amount
          }
        }
      }
    }
  }
`;

Use Case 2: Cart Page with Add-to-Cart

We built a cart page in app/routes/cart.tsx, using useCart to manage add-to-cart functionality and CartLineItem for rendering:

import { useCart, CartLineItem, CartCheckoutButton } from "@shopify/hydrogen";
import { json } from "@shopify/remix-oxygen";
import { Link } from "@remix-run/react";

export async function action({ request, context }) {
  const { cart } = context;
  const formData = await request.formData();
  const variantId = formData.get("variantId");
  await cart.addLines([{ merchandiseId: variantId, quantity: 1 }]);
  return json({ status: "success" });
}

export default function CartPage() {
  const { lines } = useCart();

  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold">Your Cart</h1>
      {lines.length === 0 ? (
        <p className="mt-6">
          Your cart is empty.{" "}
          <Link to="/products" className="text-blue-500">
            Shop now
          </Link>
          .
        </p>
      ) : (
        <div className="mt-6">
          {lines.map((line) => (
            <CartLineItem key={line.id} line={line} />
          ))}
          <CartCheckoutButton className="mt-4 bg-blue-500 text-white px-6 py-2 rounded" />
        </div>
      )}
    </div>
  );
}

We added an “Add to Cart” button to the product page by modifying app/routes/products.$handle.tsx:

// Add to existing ProductPage component
<form method="POST" action="/cart" className="mt-4">
  <input type="hidden" name="variantId" value={variantId} />
  <button
    type="submit"
    disabled={!consent || !variantId}
    className="bg-green-500 text-white px-6 py-2 rounded"
  >
    Add to Cart
  </button>
</form>

Use Case 3: Search Page with Filtering

We built a search page in app/routes/search.tsx, using useShopQuery to fetch products with filters (e.g., price, category):

import {defer} from '@shopify/remix-oxygen';
import {useLoaderData, Link} from '@remix-run/react';
import {Image} from '@shopify/hydrogen';
import {useState} from 'react';

export async function loader({context, request}) {
  const {storefront} = context;
  const url = new URL(request.url);
  const searchTerm = url.searchParams.get('q') || '';
  const {products} = await storefront.query(SEARCH_QUERY, {
    variables: {query: searchTerm, first: 10},
  });
  return defer({products, searchTerm});
}

export default function SearchPage() {
  const {products, searchTerm} = useLoaderData();
  const [query, setQuery] = useState(searchTerm);

  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold">Search Products</h1>
      <form className="mt-6">
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search sneakers..."
          className="w-full p-2 border rounded"
        />
        <button type="submit" formAction="/search" className="mt-2 bg-blue-500 text-white px-4 py-2 rounded">
          Search
        </button>
      </form>
      <div className="grid grid-cols-3 gap-4 mt-6">
        {products.nodes.map((product) => (
          <Link key={product.id} to={`/products/${product.handle}`} className="border p-4 rounded">
            <Image data={product.images.nodes[0] || {url: '/placeholder.jpg'}} className="w-full h-48 object-cover" />
            <h3 className="text-lg font-semibold mt-2">{product.title}</h3>
            <p className="text-gray-600">${product.variants.nodes[0]?.price.amount || 'N/A'}</p>
          </Link>
        ))}
      </div>
    </div>
  );
}

const SEARCH_QUERY = `#graphql
  query ($query: String!, $first: Int!) {
    products(query: $query, first: $first) {
      nodes {
        id
        handle
        title
        images(first: 1) {
          nodes {
            url
          }
        }
        variants(first: 1) {
          nodes {
            price {
              amount
            }
          }
        }
      }
    }
  }
`;

References

  1. https://shopify.dev/docs/api/hydrogen
  2. https://shopify.engineering/how-we-built-hydrogen
  3. https://hydrogen.shopify.dev/updates