The Liquid-React Bridge Intermediate 12 min read

Passing Data from Liquid to React: Patterns Overview

Learn the different patterns for passing Shopify data from Liquid templates to React components. Understand when to use each approach.

React components running in the browser have no direct access to Shopify’s data—products, collections, cart contents, and shop settings all live on the server. Liquid is our bridge. In this module, we’ll explore the patterns for passing data from Liquid to React.

The Core Challenge

Liquid templates execute on Shopify’s servers. React runs in the browser. They never execute at the same time:

SERVER (Liquid) BROWSER (React)
───────────────── ─────────────────
1. Request comes in
2. Liquid accesses Shopify data
3. Liquid renders HTML
4. HTML sent to browser ─────────▶ 5. Browser receives HTML
6. JavaScript loads
7. React mounts
8. React needs data... 🤔

By the time React runs, Liquid is long finished. So how does React get the data it needs?

Answer: Liquid embeds the data in the HTML before sending it to the browser. React then reads this embedded data.

Three Patterns for Data Transfer

We’ll cover three patterns, each suited for different scenarios:

PatternBest ForComplexity
Data AttributesSimple values (IDs, flags, single strings)Low
JSON Script TagsComplex objects (products, cart, settings)Medium
Global Window ObjectsShared configuration, multi-component dataMedium

Pattern 1: Data Attributes

The simplest approach—embed data directly on the mount point element:

{%- comment -%} Liquid template {%- endcomment -%}
<div
id="product-form-root"
data-product-id="{{ product.id }}"
data-available="{{ product.available }}"
data-handle="{{ product.handle }}"
></div>
// React component
function ProductForm() {
// Get the mount point element.
const root = document.getElementById('product-form-root');
// Read data attributes from the element.
// Note: dataset values are always strings from the DOM.
const productId = root?.dataset.productId;
const available = root?.dataset.available === 'true'; // Parse string to boolean.
const handle = root?.dataset.handle;
// Use the data...
}

When to use: Simple values like IDs, boolean flags, or short strings.

Limitations: Not suitable for complex objects, arrays, or large data sets.

Pattern 2: JSON Script Tags

Embed complex data as JSON in a non-executing script tag:

{%- comment -%} Liquid template {%- endcomment -%}
<div id="product-form-root"></div>
<script type="application/json" id="product-data">
{
"id": {{ product.id }},
"title": {{ product.title | json }},
"available": {{ product.available }},
"variants": {{ product.variants | json }},
"options": {{ product.options_with_values | json }}
}
</script>
// React component
function ProductForm() {
// Find the JSON script tag by its ID.
const dataEl = document.getElementById('product-data');
// Parse the JSON content. Provide empty object as fallback.
const product = JSON.parse(dataEl?.textContent || '{}');
console.log(product.title); // "Cool T-Shirt"
console.log(product.variants); // [{id: 123, ...}, ...] - Full objects, not strings!
}

When to use: Products, collections, cart data, or any complex objects.

Key detail: type="application/json" prevents the browser from executing the script.

Pattern 3: Global Window Objects

Set data on the global window object for access anywhere:

{%- comment -%} In theme.liquid or a snippet {%- endcomment -%}
<script>
window.Shopify = window.Shopify || {};
window.Shopify.shop = {
name: {{ shop.name | json }},
currency: {{ shop.currency | json }},
moneyFormat: {{ shop.money_format | json }},
locale: {{ shop.locale | json }}
};
window.Shopify.customer = {% if customer %}{{ customer | json }}{% else %}null{% endif %};
window.Shopify.cart = {{ cart | json }};
</script>
// Anywhere in your React app - global data is always accessible.
const shopName = window.Shopify?.shop?.name; // Optional chaining for safety.
const isLoggedIn = !!window.Shopify?.customer; // Double-bang converts to boolean.
const cartCount = window.Shopify?.cart?.item_count || 0; // Default to 0 if undefined.

When to use: Data needed by multiple components, or app-wide configuration.

Tradeoff: Pollutes global scope, but very convenient for shared data.

Choosing the Right Pattern

Here’s a decision guide:

Is the data a simple value (ID, flag, string)?
└─ Yes → Use DATA ATTRIBUTES
└─ No ↓
Is the data needed by only one component?
└─ Yes → Use JSON SCRIPT TAG
└─ No ↓
Is the data needed by multiple components or globally?
└─ Yes → Use GLOBAL WINDOW OBJECT

Real-World Example: Product Page

A typical product page might use all three patterns:

{%- comment -%} shopify-theme/sections/product-main.liquid {%- endcomment -%}
{%- comment -%} Pattern 1: Simple mount point with basic data {%- endcomment -%}
<div
id="product-form-root"
data-section-id="{{ section.id }}"
></div>
{%- comment -%} Pattern 2: Complex product data {%- endcomment -%}
<script type="application/json" id="product-json">
{
"id": {{ product.id }},
"title": {{ product.title | json }},
"handle": {{ product.handle | json }},
"available": {{ product.available }},
"price": {{ product.price }},
"compareAtPrice": {{ product.compare_at_price | default: 'null' }},
"images": [
{%- for image in product.images -%}
{
"id": {{ image.id }},
"src": {{ image.src | image_url: width: 1000 | json }},
"alt": {{ image.alt | json }}
}{% unless forloop.last %},{% endunless %}
{%- endfor -%}
],
"variants": {{ product.variants | json }},
"options": {{ product.options_with_values | json }}
}
</script>
{%- comment -%} Pattern 3: Global shop/customer data (usually in theme.liquid) {%- endcomment -%}

Type Safety with TypeScript

Each pattern can be typed for safety:

src/types/shopify.ts
// For data attributes - values are ALWAYS strings from the DOM.
interface ProductMountData {
productId: string;
available: string; // "true" or "false", not boolean.
handle: string;
}
// For JSON script tags - types are preserved from JSON.
interface ProductData {
id: number; // Actual number, not string.
title: string;
handle: string;
available: boolean; // Actual boolean.
price: number;
compareAtPrice: number | null;
images: ProductImage[];
variants: ProductVariant[];
options: ProductOption[];
}
// For window object - extend the global Window interface.
declare global {
interface Window {
Shopify?: {
shop?: ShopData;
customer?: CustomerData | null; // null when logged out.
cart?: CartData;
};
}
}

SEO Considerations

An important benefit of all these patterns: Liquid renders SEO content before React loads:

<section class="product-main">
{%- comment -%} This content is visible to search engines {%- endcomment -%}
<h1>{{ product.title }}</h1>
<p class="price">{{ product.price | money }}</p>
<div class="description">{{ product.description }}</div>
{%- comment -%} React enhances this area with interactivity {%- endcomment -%}
<div id="product-form-root"></div>
<script type="application/json" id="product-json">
{{ product | json }}
</script>
</section>

Search engines see the full content. React adds interactivity on top.

Common Pitfalls

1. Forgetting to Escape JSON

Always use the json filter for strings:

{%- comment -%} Wrong - breaks if title has quotes {%- endcomment -%}
"title": "{{ product.title }}"
{%- comment -%} Correct {%- endcomment -%}
"title": {{ product.title | json }}

2. Nil Values in JSON

Handle nil values explicitly:

{%- comment -%} Wrong - outputs empty string, invalid JSON {%- endcomment -%}
"compareAtPrice": {{ product.compare_at_price }}
{%- comment -%} Correct {%- endcomment -%}
"compareAtPrice": {{ product.compare_at_price | default: 'null' }}

3. Reading Data Before It Exists

Ensure the DOM is ready:

// Wrong - might run before DOM is ready (at module load time).
const data = document.getElementById('product-json');
// Correct - useEffect runs after component mounts, DOM is guaranteed ready.
useEffect(() => {
const data = document.getElementById('product-json');
// Now safe to access DOM elements.
}, []);

What’s Coming Next

In the following lessons, we’ll dive deep into each pattern:

  1. Data Attributes — Simple component hydration
  2. JSON Script Tags — Complex data serialization
  3. Global Window Objects — Shared state initialization
  4. Custom Data Bridge — A utility that unifies all patterns

Key Takeaways

  1. Liquid runs on the server, React in the browser—they never overlap
  2. Three patterns for data transfer: attributes, JSON scripts, window objects
  3. Choose based on complexity: simple values → attributes, complex → JSON, shared → window
  4. Always use the json filter when embedding Liquid variables in JSON
  5. SEO content renders in Liquid—React enhances with interactivity

In the next lesson, we’ll master the data attributes pattern for simple component hydration.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...