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.
Image credit: Photo by @__matthoffman__ on Unsplash
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.
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:
Hydrogen powers stores like Lady Gaga’s celebrity merch shop and Manors’ golf apparel storefront, delivering scalable, personalized experiences.
Shopify Hydrogen blends developer efficiency with business impact, redefining e-commerce.
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.
We initialized a Hydrogen project using Remix’s App Router and set up Shopify integration:
npm create @shopify/hydrogen@latest -- --template hello-world
.npm i @shopify/hydrogen @shopify/remix-oxygen @shopify/hydrogen-ui vite tailwindcss
..env
:PUBLIC_STOREFRONT_API_TOKEN=shpat_xxx PUBLIC_SHOPIFY_DOMAIN=myshop.myshopify.com
tailwind.config.js
and app/tailwind.css
:@tailwind base; @tailwind components; @tailwind utilities;
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 } } } } } `;
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>
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 } } } } } } `;
Welcome to Tech Exploration, where Ketryon tests innovative tools to power modern solutions. In this edition, we dive into building AI-driven applications with Vercel AI SDK.
Welcome to Tech Exploration, where Ketryon tests innovative tools to power modern solutions. In this edition, we dive into automating email marketing with Klaviyo and Zapier.