The Liquid-React Bridge Intermediate 12 min read

JSON Script Tags: Complex Data Serialization

Pass complex Shopify objects like products, variants, and cart data to React using JSON script tags. The workhorse pattern for real e-commerce components.

When you need to pass products, variants, cart items, or any complex data to React, JSON script tags are your go-to pattern. They’re powerful, flexible, and perfect for the rich data structures Shopify provides.

How JSON Script Tags Work

A script tag with type="application/json" is not executed by the browser—it’s just a data container:

<script type="application/json" id="my-data">
{ "name": "Example", "count": 42, "active": true }
</script>

JavaScript can read the content as text and parse it:

const element = document.getElementById('my-data');
// Parse the JSON text content into a JavaScript object.
const data = JSON.parse(element.textContent);
console.log(data.name); // "Example" - string
console.log(data.count); // 42 (actual number, not "42"!)
console.log(data.active); // true (actual boolean, not "true"!)
// JSON preserves types - unlike data attributes.

Key advantage: Unlike data attributes, JSON preserves types—numbers stay numbers, booleans stay booleans, arrays stay arrays.

Basic Product Data Example

Liquid Template:

{%- comment -%} shopify-theme/sections/product-main.liquid {%- endcomment -%}
<section class="product-main">
{%- comment -%} SEO content {%- endcomment -%}
<h1>{{ product.title }}</h1>
{%- comment -%} React mount point {%- endcomment -%}
<div id="product-form-root"></div>
{%- comment -%} Product data for React {%- endcomment -%}
<script type="application/json" id="product-data">
{
"id": {{ product.id }},
"title": {{ product.title | json }},
"handle": {{ product.handle | json }},
"available": {{ product.available }},
"price": {{ product.price }},
"compareAtPrice": {{ product.compare_at_price | default: 'null' }},
"vendor": {{ product.vendor | json }},
"type": {{ product.type | json }},
"tags": {{ product.tags | json }}
}
</script>
</section>

React Component:

src/components/product/ProductForm.tsx
import { useState, useEffect } from 'react';
// TypeScript interface for type safety.
interface Product {
id: number;
title: string;
handle: string;
available: boolean;
price: number;
compareAtPrice: number | null;
vendor: string;
type: string;
tags: string[];
}
export function ProductForm() {
const [product, setProduct] = useState<Product | null>(null);
// Read JSON data on component mount.
useEffect(() => {
const dataElement = document.getElementById('product-data');
if (dataElement?.textContent) {
try {
// Parse JSON into typed object.
const data = JSON.parse(dataElement.textContent);
setProduct(data);
} catch (error) {
// Handle invalid JSON gracefully.
console.error('Failed to parse product data:', error);
}
}
}, []);
// Don't render until data is loaded.
if (!product) return null;
return (
<div className="product-form">
<p className="vendor">{product.vendor}</p>
<div className="price">
{/* Show compare price only if it exists. */}
{product.compareAtPrice && (
<span className="compare-price">{formatMoney(product.compareAtPrice)}</span>
)}
<span className="current-price">{formatMoney(product.price)}</span>
</div>
{/* More form elements... */}
</div>
);
}

Complete Product with Variants

For a full product form, you need variants, options, and images:

<script type="application/json" id="product-data">
{
"id": {{ product.id }},
"title": {{ product.title | json }},
"handle": {{ product.handle | json }},
"available": {{ product.available }},
"description": {{ product.description | json }},
"images": [
{%- for image in product.images -%}
{
"id": {{ image.id }},
"src": {{ image.src | image_url: width: 1200 | json }},
"srcset": {
"400": {{ image.src | image_url: width: 400 | json }},
"800": {{ image.src | image_url: width: 800 | json }},
"1200": {{ image.src | image_url: width: 1200 | json }}
},
"alt": {{ image.alt | default: product.title | json }},
"width": {{ image.width }},
"height": {{ image.height }}
}{% unless forloop.last %},{% endunless %}
{%- endfor -%}
],
"variants": [
{%- for variant in product.variants -%}
{
"id": {{ variant.id }},
"title": {{ variant.title | json }},
"price": {{ variant.price }},
"compareAtPrice": {{ variant.compare_at_price | default: 'null' }},
"available": {{ variant.available }},
"sku": {{ variant.sku | json }},
"options": {{ variant.options | json }},
"image": {% if variant.image %}{{ variant.image.id }}{% else %}null{% endif %}
}{% unless forloop.last %},{% endunless %}
{%- endfor -%}
],
"options": [
{%- for option in product.options_with_values -%}
{
"name": {{ option.name | json }},
"position": {{ option.position }},
"values": {{ option.values | json }}
}{% unless forloop.last %},{% endunless %}
{%- endfor -%}
],
"selectedVariantId": {{ product.selected_or_first_available_variant.id }}
}
</script>

TypeScript Types for Products

Define comprehensive types for type safety:

src/types/shopify.ts
/**
* Product image with responsive srcset.
*/
export interface ProductImage {
id: number;
src: string;
srcset: {
'400': string; // Small size for thumbnails.
'800': string; // Medium size for cards.
'1200': string; // Large size for galleries.
};
alt: string;
width: number;
height: number;
}
/**
* Product variant - a specific combination of options.
*/
export interface ProductVariant {
id: number;
title: string; // e.g., "Red / Large"
price: number; // In cents.
compareAtPrice: number | null; // Original price for sale items.
available: boolean;
sku: string;
options: string[]; // e.g., ["Red", "Large"]
image: number | null; // Image ID, or null if no variant image.
}
/**
* Product option like "Color" or "Size".
*/
export interface ProductOption {
name: string; // e.g., "Color"
position: number; // Order of option (1-based).
values: string[]; // e.g., ["Red", "Blue", "Green"]
}
/**
* Complete product data structure.
*/
export interface Product {
id: number;
title: string;
handle: string;
available: boolean;
description: string;
images: ProductImage[];
variants: ProductVariant[];
options: ProductOption[];
selectedVariantId: number; // Initially selected variant.
}

Cart Data Serialization

Cart data follows a similar pattern:

<script type="application/json" id="cart-data">
{
"token": {{ cart.token | json }},
"itemCount": {{ cart.item_count }},
"totalPrice": {{ cart.total_price }},
"totalDiscount": {{ cart.total_discount }},
"note": {{ cart.note | json }},
"items": [
{%- for item in cart.items -%}
{
"key": {{ item.key | json }},
"productId": {{ item.product_id }},
"variantId": {{ item.variant_id }},
"title": {{ item.title | json }},
"variantTitle": {{ item.variant.title | json }},
"quantity": {{ item.quantity }},
"price": {{ item.price }},
"linePrice": {{ item.final_line_price }},
"discountedPrice": {{ item.final_price }},
"image": {{ item.image | image_url: width: 200 | json }},
"url": {{ item.url | json }},
"handle": {{ item.handle | json }},
"properties": {{ item.properties | json }}
}{% unless forloop.last %},{% endunless %}
{%- endfor -%}
]
}
</script>

Utility Function for JSON Script Tags

Create a reusable utility:

src/utils/json-data.ts
/**
* Reads and parses JSON from a script tag.
* @param elementId - The ID of the script element.
* @returns Parsed JSON data, or null if not found/invalid.
*/
export function readJsonScript(elementId: string): unknown {
const element = document.getElementById(elementId);
// Check if element exists and has content.
if (!element?.textContent) {
console.warn(`JSON script tag #${elementId} not found or empty`);
return null;
}
try {
// Parse and return the JSON.
return JSON.parse(element.textContent);
} catch (error) {
// Log error but don't crash - return null instead.
console.error(`Failed to parse JSON from #${elementId}:`, error);
return null;
}
}
/**
* Reads JSON with a fallback value if not found.
* Useful for optional data that has sensible defaults.
*/
export function readJsonScriptOrDefault(elementId: string, defaultValue: unknown): unknown {
return readJsonScript(elementId) ?? defaultValue;
}

Usage:

import { readJsonScript } from '@/utils/json-data';
import type { Product, Cart } from '@/types/shopify';
// In a component - cast the result to your expected type.
// readJsonScript returns `unknown`, so we need type assertions.
const product = readJsonScript('product-data') as Product | null;
const cart = readJsonScript('cart-data') as Cart | null;
// Now TypeScript knows the shape of the data.

React Hook for JSON Data

For a more React-idiomatic approach:

src/hooks/useJsonData.ts
import { useState, useEffect } from 'react';
/**
* Simple hook that reads and parses JSON from a script tag.
* Returns null initially, then the parsed data after mount.
*/
export function useJsonData(elementId: string): unknown {
const [data, setData] = useState<unknown>(null);
useEffect(() => {
const element = document.getElementById(elementId);
if (element?.textContent) {
try {
setData(JSON.parse(element.textContent));
} catch (error) {
console.error(`Failed to parse JSON from #${elementId}:`, error);
}
}
}, [elementId]); // Re-run if elementId changes.
return data;
}
/**
* Enhanced hook with loading and error states.
* Useful when you need to show loading/error UI.
*/
export function useJsonDataWithStatus(elementId: string) {
const [data, setData] = useState<unknown>(null);
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
useEffect(() => {
const element = document.getElementById(elementId);
// No element or empty content = error state.
if (!element?.textContent) {
setStatus('error');
return;
}
try {
setData(JSON.parse(element.textContent));
setStatus('success');
} catch (error) {
console.error(`Failed to parse JSON from #${elementId}:`, error);
setStatus('error');
}
}, [elementId]);
// Return data, status, and convenience boolean.
return { data, status, isLoading: status === 'loading' };
}

Usage:

import type { Product } from '@/types/shopify';
// Simple usage - just get the data.
function ProductForm() {
const data = useJsonData('product-data');
const product = data as Product | null; // Type assertion for TS.
if (!product) return <LoadingSpinner />; // Shows while data loads.
return <Form product={product} />;
}
// Enhanced usage - handle all states explicitly.
function ProductPage() {
const { data, status } = useJsonDataWithStatus('product-data');
const product = data as Product | null;
if (status === 'loading') return <Skeleton />; // Initial loading state.
if (status === 'error') return <ErrorMessage />; // Data not found or invalid.
return <ProductDisplay product={product!} />; // Non-null assertion OK here.
}

Handling Liquid Edge Cases

Nil Values

{%- comment -%} Handle nil/empty values {%- endcomment -%}
"compareAtPrice": {{ product.compare_at_price | default: 'null' }},
"metafield": {{ product.metafields.custom.field.value | default: 'null' | json }},

Empty Arrays

{%- comment -%} Empty arrays are fine {%- endcomment -%}
"tags": {{ product.tags | json }}, {%- comment -%} Outputs [] if empty {%- endcomment -%}

Strings with Special Characters

{%- comment -%} Always use | json for strings {%- endcomment -%}
"title": {{ product.title | json }}, {%- comment -%} Handles quotes, newlines, etc. {%- endcomment -%}
"description": {{ product.description | json }},

HTML Content

{%- comment -%} For raw HTML {%- endcomment -%}
"descriptionHtml": {{ product.description | json }},
{%- comment -%} For plain text {%- endcomment -%}
"descriptionText": {{ product.description | strip_html | json }},

Multiple JSON Blocks

You can have multiple JSON script tags for different data:

{%- comment -%} Product data {%- endcomment -%}
<script type="application/json" id="product-data">
{{ product | json }}
</script>
{%- comment -%} Section settings {%- endcomment -%}
<script type="application/json" id="section-settings">
{
"showQuantity": {{ section.settings.show_quantity | json }},
"showSku": {{ section.settings.show_sku | json }},
"imageZoom": {{ section.settings.image_zoom | json }}
}
</script>
{%- comment -%} Related products (if available) {%- endcomment -%}
{%- if recommendations.performed -%}
<script type="application/json" id="recommendations-data">
{{ recommendations.products | json }}
</script>
{%- endif -%}

Using Shopify’s Built-in JSON Filter

Shopify’s | json filter handles most serialization automatically:

{%- comment -%} Simple approach for standard objects {%- endcomment -%}
<script type="application/json" id="product-data">
{{ product | json }}
</script>
<script type="application/json" id="cart-data">
{{ cart | json }}
</script>

This outputs Shopify’s default JSON representation. For custom shapes, build the JSON manually as shown earlier.

Performance Tip: Lazy Parsing

For large data sets, consider lazy parsing:

// Reference the element once - don't parse yet.
const productDataElement = document.getElementById('product-data');
// Parse on demand, not at load time.
function getProduct(): Product | null {
if (!productDataElement?.textContent) return null;
return JSON.parse(productDataElement.textContent);
}
// Only parse when actually needed.
function handleAddToCart() {
const product = getProduct(); // Parsing happens here, when needed.
if (product) {
// Use product data
}
}
// Useful for large data sets that may not always be used.

Key Takeaways

  1. Use type="application/json" to prevent script execution
  2. JSON preserves types: numbers, booleans, arrays all work correctly
  3. Always use | json filter for string values in Liquid
  4. Handle nil values with | default: 'null'
  5. Create reusable utilities/hooks for consistent data access
  6. Define TypeScript interfaces for type safety
  7. For simple Shopify objects, {{ object | json }} works great

In the next lesson, we’ll explore global window objects for data that needs to be accessed by multiple components.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...