Headless Commerce with Shopify, Hydrogen, and Storyblok

In this tutorial, we’ll explore how to integrate Storyblok into a Shopify-based e-commerce website. Shopify is a leading e-commerce platform, and Storyblok is a powerful headless CMS that empowers developers to create flexible frontends while giving content editors intuitive visual control.

We’ll use Hydrogen, Shopify’s React-based framework for building custom storefronts, which is built on Remix for straightforward server-side rendering and routing.

Prerequisites

hint:

This tutorial won’t cover designing a complete e-commerce site—that’s beyond its scope. Instead, we’ll focus on the core concepts and logic behind integrating Storyblok with Hydrogen, equipping you to customize your site as needed.

I’ll use a demo store for this guide, but you can use your own. If you don’t have one, create a Shopify development store. We’ll start with the default Hydrogen template and pull in demo content. If you’re new to Hydrogen, I recommend reviewing the Hydrogen documentation first.

Understanding the Default Hydrogen Homepage

Once your Hydrogen site is running locally with demo store content, you’ll notice the default homepage renders just two components:

<FeaturedCollection collection={data.featuredCollection} />
<RecommendedProducts products={data.recommendedProducts} />

These represent the homepage’s only UI blocks:

  • FeaturedCollection
  • RecommendedProducts

Let’s examine RecommendedProducts first. it fetches products with this query:

products(first: 4, sortKey: UPDATED_AT, reverse: true)

This isn’t a curated list—it simply grabs the four most recently updated products. Our goal is to connect Storyblok to this component, allowing editors to hand-pick products and manage the homepage via the Visual Editor.

Setting Up the Storyblok Shopify App

Start with a new, blank Storyblok Space (or use an existing one). You’ll also need to install the Storyblok Shopify v2 app. Follow the installation guide on the app page to connect it to your Shopify store.

In Storyblok, create a new block called recommended_products with two fields:

  • title (Text)
  • products (Plugin)

For the products field, configure it as a plugin with these settings:

Custom Type: sb-shopify-v2
Source: self
Options:
storeDomain: https://yourshopifyapp-domain.myshopify.com/
limit: 4
selectOnly: product

Now you have a reusable block that lets editors:

  • Select specific products to display
  • Edit the section title dynamically

Next, we’ll fetch and render this data on the frontend. Since Hydrogen uses Remix, check out Storyblok’s 5-minute Remix guide if you’re unfamiliar—it’ll ease you into the process.

Connecting Storyblok to Hydrogen

In root.jsx, initialize Storyblok:

root.jsx
import {storyblokInit, apiPlugin} from '@storyblok/react';
import Page from './storyblok/Page';
import RecommendedProducts from './storyblok/RecommendedProducts';

const components = {
  page: Page,
  recomended_products: RecommendedProducts,
};

storyblokInit({
  accessToken: 'mS....', // Use an environment variable instead
  use: [apiPlugin],
  components,
});

In index.jsx, fetch the recommendedproducts block from the homepage story:

_index.jsx
let {data} = await getStoryblokApi().get('cdn/stories/home', {
  version: 'draft',
});

Rendering in the UI

Replace the default RecommendedProducts component with StoryblokComponent to dynamically render Storyblok blocks:

_index.jsx
export default function Homepage() {
  const data = useLoaderData();
  let story = useStoryblokState(data.story);

  return (
    <div className="home">
      <FeaturedCollection collection={data.featuredCollection} />
      <StoryblokComponent blok={story.content} />
    </div>
  );
}

The component map is already defined in root.jsx.

Designing RecommendedProducts

Here’s the component:

src/storyblok/RecommendedProducts.jsx
import {storyblokEditable} from '@storyblok/react';
import ProductCard from './ProductCard';

export default function RecommendedProducts({blok}) {
  return (
    <div className="recommended-products" {...storyblokEditable(blok)}>
      <h2>{blok.title}</h2>
      <div className="recommended-products-grid">
        {blok.products.items.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

For ProductCard, start simple:

src/storyblok/ProductCard.jsx
<p>{JSON.stringify(product)}</p>

This setup lets you:

  • Pick four products in Storyblok
  • Reorder them visually in the Visual Editor
  • See updates reflected instantly

While JSON.stringify(product) confirms dynamic data works, let’s polish the UI to match the default RecommendedProducts component.

Getting Full Product Data from Shopify

The Storyblok Shopify plugin returns limited data (e.g., no prices). This is intentional—Storyblok caches content for speed, but dynamic data such as prices should come from Shopify at runtime for accuracy.

We’ll use the handle from Storyblok to fetch complete product details via Shopify’s Storefront API.

Define a function:

src/lib/loadProductByHandle.js
export async function loadProductByHandle({context, handle}) {
  const {storefront} = context;

  if (!handle) {
    throw new Error('Expected product handle to be defined');
  }

  const [{product}] = await Promise.all([
    storefront.query(PRODUCT_QUERY, {
      variables: {handle},
    }),
  ]);

  return product;
}

Set up the GraphQL query:

src/lib/loadProductByHandle.js
const PRODUCT_FRAGMENT = `#graphql
fragment RecommendedProduct on Product {
    id
    title
    handle
    priceRange {
      minVariantPrice {
        amount
        currencyCode
      }
    }
    images(first: 1) {
      nodes {
        id
        url
        altText
        width
        height
      }
    }
  }
`;

const PRODUCT_QUERY = `#graphql
  query Product(
    $country: CountryCode
    $handle: String!
    $language: LanguageCode
  ) @inContext(country: $country, language: $language) {
    product(handle: $handle) {
      ...RecommendedProduct
    }
  }
  ${PRODUCT_FRAGMENT}
`;

This fetches real-time product data (title, price, image) from Shopify.

Enrich Storyblok Data in _index.jsx Loader

Update the loader:

src/routes/_index.jsx
export async function loader(args) {
  const criticalData = await loadCriticalData(args);

  const { data } = await getStoryblokApi().get('cdn/stories/home', {
    version: 'draft',
  });

  const story = data?.story;
  if (!story?.content?.body) {
    return { ...criticalData, story };
  }
  
  const enrichedBody = await Promise.all(
    story.content.body.map(async (blok) => {
      if (blok.component !== 'recomended_products') return blok;

      const enrichedItems = await Promise.all(
        (blok.products?.items || []).map((product) =>
          loadProductByHandle({
            context: args.context,
            handle: product.handle,
          }),
        ),
      );

      return {
        ...blok,
        products: {
          ...blok.products,
          items: enrichedItems.filter(Boolean),
        },
      };
    }),
  );

  story.content.body = enrichedBody;
  return { ...criticalData, story };
}

Now, JSON.stringify(product) shows richer data, including prices. But reordering in Storyblok revert to basic data—let’s fix that.

Why Product Data Reverts in Preview

The useStoryblokState() hook in Homepage enables live updates from the Visual Editor. However, enriched data (fetched server-side) isn’t included in client-side updates, causing a fallback to Storyblok’s basic data.

Fix: Add a Dynamic API Endpoint

Create routes/api.product.$handel.js:

routes/api.product.$handel.js
export async function loader({ context, params }) {
	const handle = params.handel;
	const productData = await loadProductByHandle({ context, handle });
	return { ...productData };
}

This API uses the same loadProductByHandle function we created earlier to return full product data as JSON, enabling client-side fetching during live previews.

Designing ProductCard

Update ProductCard with SWR for dynamic fetching:

src/storyblok/ProductCard.jsx
import {Link} from '@remix-run/react';
import {Money, Image} from '@shopify/hydrogen';
import useSWR from 'swr';

export default function ProductCard({product: initialProduct}) {
  const {
    data: product,
    error,
    isLoading,
  } = useSWR(
    initialProduct?.handle ? `/api/product/${initialProduct.handle}` : null,
    (url) => fetch(url).then((res) => res.json()),
  );

  if (isLoading || !product || !product.handle) {
    return <p>Loading...</p>;
  }

  if (error) {
    return <p>Error loading product</p>;
  }

  return (
    <Link className="recommended-product" to={`/products/${product.handle}`}>
      <Image
        data={product.images.nodes[0]}
        aspectRatio="1/1"
        sizes="(min-width: 45em) 20vw, 50vw"
      />
      <h4>{product.title}</h4>
      <small>
        <Money data={product.priceRange.minVariantPrice} />
      </small>
    </Link>
  );
}

SWR ensures fresh data during live edits, keeping the preview seamless.

Optional Optimization

Limit SWR fetching to preview mode, skipping it in production for better performance.

Summary

You’ve built a reusable RecommendedProducts block that:

  • Is powered by Shopify and Storyblok
  • Fetches accurate, real-time product data from Shopify
  • Supports live drag-and-drop previews via StoryblokComponent
  • Empowers editors to control the homepage without developer help

Author

Dipankar Maikap

Dipankar Maikap

Dipankar is a seasoned Developer Relations Engineer at Storyblok, with a specialization in frontend development. His expertise spans across various JavaScript frameworks such as Astro, Next.js, and Remix. Passionate about web development and JavaScript, he remains at the forefront of the ever-evolving tech landscape, continually exploring new technologies and sharing insights with the community.