Headless Commerce with Shopify, Hydrogen, and Storyblok
Storyblok is the first headless CMS that works for developers & marketers alike.
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
- A Storyblok account
- Familiarity with Storyblok’s headless concepts, such as blocks, stories, and the Visual Editor
- Basic knowledge of Hydrogen (we’ll use Shopify’s Hydrogen starter template)
- A Shopify account with a development or live store
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.
Create the recommended_products block
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:
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:
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:
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:
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:
<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:
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:
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:
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
:
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:
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