The Rendering Gap in Shopify Themes
Shopify themes are server-rendered by Liquid. The HTML arrives fully formed from Shopify's CDN, fast, SEO-friendly, cacheable. But many storefront interactions need real-time updates without full page reloads: adding to cart, updating quantities, applying discount codes, filtering collections. The traditional solution was to fetch raw JSON data from Shopify's AJAX APIs and rebuild HTML client-side using JavaScript templates. This works, but it means maintaining two rendering paths, Liquid for the initial render and JavaScript for updates, which leads to visual inconsistencies and duplicated logic.
The Section Rendering API solves this by letting you request server-rendered Liquid sections via AJAX. You get back the same HTML that Liquid would produce on a full page load, then swap it into the DOM. One rendering source, two delivery mechanisms. This is the foundation of Shopify's hybrid rendering model, and it works without any framework dependency.
How the Section Rendering API Works
The mechanics are straightforward. Any Shopify AJAX endpoint, Cart API, Product Recommendations, Predictive Search, accepts a sections parameter. When included, the JSON response contains a sections object where each key is a section filename and the value is the fully rendered HTML string.
const response = await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: [{ id: variantId, quantity: 1 }],
sections: 'cart-drawer,cart-icon-bubble'
})
});
const data = await response.json();
// data.sections = {
// 'cart-drawer': '<div id="cart-drawer">...rendered HTML...</div>',
// 'cart-icon-bubble': '<span class="cart-count">3</span>'
// }
The sections parameter can be a comma-separated string ('cart-drawer,cart-icon-bubble') or an array (['cart-drawer', 'cart-icon-bubble']). Each section name corresponds to a .liquid file in your theme's sections/ directory. The API renders that section using the current request context, the user's cart state, locale, currency, and customer session are all reflected in the returned HTML.
Bundled Section Rendering
Beyond the Cart API, Shopify provides a standalone section rendering endpoint: GET /?sections=section-name. This returns only the rendered section HTML, not a full page. It's useful for refreshing sections independently of any cart or product action, updating a header notification after login, refreshing an announcement bar, or re-rendering a wishlist count.
const response = await fetch('/?sections=header,announcement-bar');
const sections = await response.json();
// sections = {
// 'header': '<header>...</header>',
// 'announcement-bar': '<div class="announcement">...</div>'
// }
Request context matters. The standalone section rendering endpoint respects the current request context, the user's cart, locale, currency, and customer login state are all reflected in the rendered HTML. This makes it safe for personalized content without any additional configuration.
Case Study: Real-Time Cart Drawer
Here's a concrete business requirement: when a customer adds a product to cart, a cart drawer slides open showing the updated contents, product images, titles, quantities, prices, subtotal, and a checkout button. The cart HTML must match exactly what the server would render on a full page load. No visual glitches, no layout shifts, no duplicated template logic.
The implementation has two parts: a Liquid section template that defines the cart drawer markup, and a JavaScript class that handles cart actions and DOM replacement.
<div id="cart-drawer" class="cart-drawer">
<div class="cart-drawer__header">
<h2>Your Cart ({{ cart.item_count }})</h2>
<button class="cart-drawer__close" aria-label="Close cart">×</button>
</div>
{%- if cart.item_count > 0 -%}
<div class="cart-drawer__items">
{%- for item in cart.items -%}
<div class="cart-item" data-line="{{ forloop.index }}">
<img src="{{ item.image | image_url: width: 150 }}"
alt="{{ item.title | escape }}" loading="lazy" />
<div class="cart-item__details">
<a href="{{ item.url }}">{{ item.product.title }}</a>
{%- unless item.product.has_only_default_variant -%}
<p class="cart-item__variant">{{ item.variant.title }}</p>
{%- endunless -%}
<div class="cart-item__quantity">
<button class="qty-btn" data-action="decrease">-</button>
<span>{{ item.quantity }}</span>
<button class="qty-btn" data-action="increase">+</button>
</div>
<p class="cart-item__price">{{ item.final_line_price | money }}</p>
</div>
</div>
{%- endfor -%}
</div>
<div class="cart-drawer__footer">
<div class="cart-drawer__subtotal">
<span>Subtotal</span>
<span>{{ cart.total_price | money }}</span>
</div>
<a href="/checkout" class="cart-drawer__checkout">Checkout</a>
</div>
{%- else -%}
<div class="cart-drawer__empty">
<p>Your cart is empty</p>
</div>
{%- endif -%}
</div>
This section template is the single source of truth for cart drawer markup. It renders with Liquid on full page loads and via the Section Rendering API on AJAX updates. No duplication.
class CartDrawer {
constructor() {
this.drawer = document.getElementById('cart-drawer');
this.bindEvents();
}
async addToCart(variantId, quantity = 1) {
const response = await fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: [{ id: variantId, quantity }],
sections: 'cart-drawer,cart-icon-bubble'
})
});
const data = await response.json();
this.renderSections(data.sections);
this.open();
}
async updateQuantity(line, quantity) {
const response = await fetch('/cart/change.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
line,
quantity,
sections: 'cart-drawer,cart-icon-bubble'
})
});
const data = await response.json();
this.renderSections(data.sections);
}
renderSections(sections) {
for (const [sectionId, html] of Object.entries(sections)) {
const wrapper = document.getElementById(
`shopify-section-${sectionId}`
);
if (wrapper) {
wrapper.innerHTML = html;
this.bindEvents();
}
}
}
bindEvents() {
this.drawer?.querySelectorAll('.qty-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const item = e.target.closest('.cart-item');
const line = parseInt(item.dataset.line);
const action = e.target.dataset.action;
const currentQty = parseInt(
item.querySelector('.cart-item__quantity span').textContent
);
const newQty = action === 'increase'
? currentQty + 1
: currentQty - 1;
this.updateQuantity(line, newQty);
});
});
}
open() { this.drawer?.classList.add('is-open'); }
close() { this.drawer?.classList.remove('is-open'); }
}
Notice how renderSections() targets the wrapper element shopify-section-{sectionId}. Shopify wraps every rendered section in a <div> with this ID pattern. After replacing the inner HTML, bindEvents() re-attaches event listeners to the new DOM elements, a critical step that's easy to forget.
Data Passing: Liquid to JavaScript
Section rendering handles HTML updates, but sometimes your JavaScript needs structured data from Liquid, product variants, metafields, inventory levels. The cleanest pattern is the JSON script tag: serialize the Liquid object into a <script type="application/json"> element, then parse it in JavaScript.
<script type="application/json" id="product-data">
{{ product | json }}
</script>
<script type="module">
const productData = JSON.parse(
document.getElementById('product-data').textContent
);
</script>
This approach is better than scattering values across data- attributes for several reasons. Complex objects, nested variants, metafield arrays, price ranges, serialize cleanly without encoding issues. The data contract between Liquid and JavaScript is explicit: if the JSON shape changes, you'll catch it immediately. And if you're using TypeScript, you can type-check the parsed object against an interface, catching mismatches at build time rather than runtime.
Reinitializing JavaScript After DOM Replacement
When you swap section HTML via renderSections(), any JavaScript bound to elements inside that section is destroyed, event listeners, framework component instances, IntersectionObservers, tooltip initializations. The DOM nodes those references pointed to no longer exist. You need a reinitialization strategy, and the most reliable one is event delegation.
Instead of binding listeners to elements that will be replaced, bind them to a stable parent (like document or a wrapper that sits outside the section boundary) and use closest() to find the actual target.
document.addEventListener('click', (e) => {
const addToCartBtn = e.target.closest('[data-add-to-cart]');
if (addToCartBtn) {
const variantId = addToCartBtn.dataset.variantId;
cartDrawer.addToCart(parseInt(variantId));
}
});
Other reinitialization patterns exist, MutationObserver can watch for DOM changes and reinitialize components automatically, and frameworks like Alpine.js provide Alpine.initTree() to boot new components after insertion. But event delegation is framework-agnostic, requires zero cleanup, and survives any number of DOM replacements without accumulating duplicate listeners.
Avoid rebinding on every render. The CartDrawer example above calls bindEvents() after each section swap. This works for simple cases, but in production you should either use event delegation or remove old listeners before adding new ones to prevent duplicate handlers stacking up on repeated updates.
Performance Profile
Server-rendered Liquid gives you excellent Core Web Vitals without JavaScript. HTML arrives fully formed from Shopify's CDN, so Time to First Byte (TTFB), First Contentful Paint (FCP), and Largest Contentful Paint (LCP) all score well before any script executes. Section rendering preserves this advantage, the initial page load is pure server-rendered HTML, and JavaScript only handles subsequent interactions.
The trade-off is bandwidth. A Cart API JSON response is roughly 0.5–2KB. The same cart rendered as section HTML is 3–8KB. A full page HTML document runs 50–200KB, while a single section response is typically 3–15KB. You're trading a few kilobytes of extra payload for the elimination of duplicate rendering logic.
The bandwidth math works out. The difference between JSON and section HTML is usually 3–5KB per request. On modern connections, this adds less than 10ms of transfer time. The engineering simplicity of maintaining a single Liquid template, no client-side template strings, no visual inconsistencies between server and client renders, far outweighs this negligible overhead.
There's another performance benefit that's less obvious: because the server renders the section with full access to Liquid's object model, you avoid the multiple API round-trips that a client-side approach might require. A single Cart API call with sections returns both the updated cart data and the rendered HTML. No follow-up requests to fetch product images, variant titles, or formatted prices, Liquid handles all of that server-side.
Combining with Other Shopify APIs
The sections parameter isn't limited to the Cart API. It works across Shopify's AJAX surface, Cart API endpoints (add, update, change, clear), Product Recommendations, and Predictive Search all accept it. This means you can use the same rendering pattern for cart drawers, "you may also like" carousels, and search result dropdowns.
Collection filtering is a particularly strong use case. When a customer selects a filter, you fetch the filtered page URL with a sections parameter, swap the product grid, and update the browser history, all without a full page reload.
async function filterCollection(params) {
const url = `${window.location.pathname}?${params.toString()}§ions=collection-grid`;
const response = await fetch(url);
const data = await response.json();
const grid = document.getElementById('shopify-section-collection-grid');
grid.innerHTML = data['collection-grid'];
window.history.pushState(
{},
'',
`${window.location.pathname}?${params.toString()}`
);
}
The pushState call updates the URL bar so the filtered state is shareable and bookmarkable. If the user refreshes the page, Liquid renders the collection with those filters server-side, same result, full-page context. The pattern is consistent regardless of how the user arrives at the filtered view.
Edge Cases and Production Considerations
Section ID format. Shopify wraps every rendered section in a <div id="shopify-section-{filename}">. When replacing content, target this wrapper, not an element inside it. If you replace the wrapper's innerHTML, the wrapper itself stays in the DOM and your selector continues to work on subsequent updates.
Alternate templates. The sections parameter renders using the current page's template context. If you need a section rendered with a different template's context, use the standalone rendering endpoint (/?sections=...) with the appropriate URL path.
Rate limits. Section rendering counts toward the same rate limits as the underlying API. A POST /cart/add.js with sections is one Cart API request, not one request plus one section rendering request. There's no additional rate limiting penalty for including sections.
Cross-section dependencies. If section A references data from section B, for example, the header cart count depends on the cart drawer's state, request both sections in the same API call. This guarantees they reflect the same cart snapshot and avoids race conditions.
Theme editor compatibility. Section rendering works in the theme editor, but the editor wraps sections in additional markup for editing controls. Your JavaScript should use querySelector patterns that tolerate this extra nesting rather than relying on exact DOM depth.
Caching. Sections that display cart data or customer-specific content are not cached by Shopify's CDN, each request renders fresh. Static sections like footers and announcement bars may be cached. Don't assume section responses will always reflect real-time state for non-personalized content.
Liquid render limits. Each section rendering request counts toward Shopify's Liquid rendering limits (50ms per section, 500ms total per request). Requesting multiple sections in a single call is more efficient than separate requests because they share the request-level budget.
Key Takeaways
- The Section Rendering API lets you request server-rendered Liquid HTML via AJAX and swap it into the DOM, one rendering source, two delivery mechanisms.
- Any Shopify AJAX endpoint (Cart API, Recommendations, Predictive Search) accepts a
sectionsparameter that returns rendered HTML alongside JSON data. - The standalone endpoint (
/?sections=...) renders sections independently of any API action, respecting the user's full request context. - Use JSON script tags (
<script type="application/json">) to pass structured Liquid data to JavaScript, cleaner than data attributes for complex objects. - Event delegation is the most reliable pattern for handling interactions after DOM replacement, bind to stable parents, not to elements that get swapped.
- The bandwidth overhead of section HTML vs. JSON is 3–5KB per request, negligible on modern connections and worth the engineering simplicity.
- Request cross-dependent sections in the same API call to guarantee consistent state.
- Section rendering is framework-agnostic, it works with vanilla JavaScript, Alpine.js, Vue, React, or any other client-side approach.
If your store needs a hybrid rendering architecture that combines Liquid's server-side speed with real-time interactivity, I can help you design and build it. Let's talk about what your storefront actually needs.