I've spent the last few years living inside headless CMS platforms: Contentful, Strapi, content modeling, migrations, and the React and Next.js frontends that sit on top of them. Lately I've been pointing that experience at Shopify, and the surprising part is how little of it I had to unlearn.
Commerce platforms and content platforms solve different business problems, but the frontend problems are the same ones: where does the data come from, who controls the layout, and how do you keep the UI from depending on a vendor's response shape?
Here is how I map the two worlds.
Two ways to build on Shopify
Before writing any code, the first decision is the approach.
A Liquid theme is the default path. The store is rendered and hosted by Shopify's Online Store, and merchants rearrange pages in the theme editor. A Hydrogen storefront is a custom React app built on React Router, talking to the Storefront API and deployed to Oxygen, Shopify's edge hosting.
Neither is "better". A theme is often the right answer when editors need control and the design fits the model. Hydrogen earns its extra engineering when the experience needs to go beyond what a theme can express. Being comfortable in both is what makes the recommendation honest.
Themes: templates are data, sections are components
Online Store 2.0 themes are built from JSON templates, sections, and blocks:
A JSON template lists which sections appear on a page and in what order. Each section is a Liquid file with its own schema. Blocks are the smaller pieces inside a section that merchants can add, remove, and reorder. If you have used flexible components or dynamic zones in a headless CMS, this will feel familiar.
Here is a small section with two block types:
<section class="rich-text">
<h2>{{ section.settings.heading }}</h2>
{%- for block in section.blocks -%}
{%- case block.type -%}
{%- when 'text' -%}
<div {{ block.shopify_attributes }}>{{ block.settings.text }}</div>
{%- when 'button' -%}
<a {{ block.shopify_attributes }} class="button" href="{{ block.settings.link }}">
{{ block.settings.label }}
</a>
{%- endcase -%}
{%- endfor -%}
</section>
{% schema %}
{
"name": "Rich text",
"settings": [
{ "type": "text", "id": "heading", "label": "Heading", "default": "Hello" }
],
"blocks": [
{
"type": "text",
"name": "Text",
"settings": [{ "type": "richtext", "id": "text", "label": "Text" }]
},
{
"type": "button",
"name": "Button",
"settings": [
{ "type": "text", "id": "label", "label": "Label", "default": "Shop now" },
{ "type": "url", "id": "link", "label": "Link" }
]
}
],
"presets": [{ "name": "Rich text" }]
}
{% endschema %}
The schema is the contract between the developer and the merchant. It decides what the editor can change, and everything else stays consistent. That is the same idea as a content model, just expressed next to the markup.
Headless: the same adapter idea
In my previous post I argued that your UI should depend on your application's data model, not the CMS's. That rule holds for Shopify too.
A Storefront API query for a product:
query Product($handle: String!) {
product(handle: $handle) {
id
title
description
featuredImage {
url
altText
}
priceRange {
minVariantPrice {
amount
currencyCode
}
}
}
}
And an adapter that maps the response to the same Product model the components already use:
type Product = {
id: string;
title: string;
description: string;
image: { url: string; alt: string };
price: { amount: string; currency: string };
};
function mapShopifyProduct(p: ShopifyProduct): Product {
return {
id: p.id,
title: p.title,
description: p.description,
image: {
url: p.featuredImage?.url ?? "",
alt: p.featuredImage?.altText ?? p.title,
},
price: {
amount: p.priceRange.minVariantPrice.amount,
currency: p.priceRange.minVariantPrice.currencyCode,
},
};
}
<ProductCard product={product} /> doesn't know or care whether the data came from Shopify, Strapi, or Contentful. If the product data moves later, only the adapter changes.
What transfers
The vocabulary changes, the thinking doesn't. Content types become metaobject definitions, custom fields become metafields, flexible page composition becomes sections and blocks, and the delivery API becomes the Storefront API. Add migration experience on top (mapping one content model to another without breaking the frontend) and a lot of Shopify work starts to look familiar.
What I care about when building storefronts
- Performance. Product and collection pages are where Core Web Vitals turn into revenue, so image handling, script weight, and render cost get attention early.
- Accessibility. Variant pickers, carts, and checkout entry points have to work with a keyboard and a screen reader.
- Merchant experience. A schema that makes sense to an editor is part of the product, not an afterthought.
- Boundaries. Keep platform-specific data at the edge, and let the UI depend on your own model.
Where I'm headed
I'm currently looking for frontend roles, with a focus on Shopify: theme development with Liquid, and headless storefronts with React and the Storefront API. If you're building in that space, I'd like to hear how you split the work between themes and headless.
Which do you reach for first on a new store: a Liquid theme, or Hydrogen? What tipped the decision?
Happy coding! 🚀



Top comments (1)
The adapter boundary is the part I wish more storefront migrations treated as a first-class deliverable. One practical addition: keep a small set of contract fixtures from both systems and run them through the mapper in CI. The fixtures should include missing images, products with no variants available, translated fields, and metafields that changed type during migration. It catches response-shape drift before a product page becomes the test environment.
For the Liquid vs Hydrogen choice, I would also ask who owns day-two changes. If merchandisers need to rearrange the page weekly, a theme with a disciplined section schema often beats a more flexible stack that requires an engineer for every change.