Skip to main content

Use the Section Rendering API for dynamic updates

Use the Section Rendering API to fetch and update specific theme sections through Ajax without a full page reload.


Without the Section Rendering API, updating page content requires a full page reload, which redownloads and re-renders the entire page. This wastes bandwidth and creates slow, jerky user experiences. The Section Rendering API requests only the sections that changed, allowing targeted updates that feel instant and improving metrics for subsequent pages.


Request sections using the sections query parameter on any page URL:

// Request up to 5 sections at once. Static sections use their filename as the ID,
// and sections in JSON templates or section groups use their dynamic ID.
fetch("/?sections=header,template--123__product-info,cart-drawer")
.then((response) => response.json())
.then((data) => {
// data.header contains rendered HTML for the header section
// data["template--123__product-info"] contains rendered HTML for the product-info section
// and so on
});

Requesting more than five sections returns 400 Bad Request and renders nothing.

Response format:

{
"header": "<div id=\"shopify-section-header\" class=\"shopify-section\"><!-- HTML content --></div>",
"template--123__product-info": "<div id=\"shopify-section-template--123__product-info\" class=\"shopify-section\"><!-- HTML content --></div>",
"cart-drawer": "<div id=\"shopify-section-cart-drawer\" class=\"shopify-section\"><!-- HTML content --></div>"
}

You can find section IDs in two ways.

Using Liquid:

<div data-section-id="{{ section.id }}">
{%- comment -%} section.id outputs the unique section ID {%- endcomment -%}
</div>

From the section wrapper in HTML:

<div id="shopify-section-template--123__product-info" class="shopify-section">
<!-- The ID is: template--123__product-info -->
</div>

Static sections use their filename as the ID: header.liquid maps to header, and footer.liquid maps to footer.

Sections in JSON templates and section groups get a dynamic ID instead, such as template--123__product-info. Requesting one of those by filename doesn't return the configured section. If a section file with that name exists, then the server renders it statically with default settings, and if it doesn't, then the request fails. On a typical Online Store 2.0 product or collection template, that means a filename-style ID replaces configured page content with default markup or nothing at all. Read the ID from {{ section.id }} or from the section wrapper instead of hardcoding it.

Anchor to Render sections in different contextsRender sections in different contexts

Sections render in the context of the page URL. You can specify any page context. Read the rendered section ID from the section element rather than hardcoding a filename. Each of the following examples runs on a page that already renders the section it requests:

// On a product page: render a different product through the same section
const productSectionId =
document.querySelector(".product-info").dataset.sectionId;
fetch(`/products/my-product?sections=${productSectionId}`).then((response) =>
response.json()
);

// On a collection page: render the next page of results
const gridSectionId = document.querySelector(".product-grid").dataset.sectionId;
fetch(`/collections/all?page=2&sections=${gridSectionId}`).then((response) =>
response.json()
);

// On a search page: render results for a new query
const resultsSectionId =
document.querySelector(".search-results").dataset.sectionId;
fetch(`/search?q=shoes&sections=${resultsSectionId}`).then((response) =>
response.json()
);

The section ID must exist in the template that renders for the URL you request. A product section ID works for another product that uses the same template, but it doesn't work against a collection or search URL.

Anchor to Optimistic cart renderingOptimistic cart rendering

When a customer adds a product to the cart, the cart drawer typically opens but shows nothing until the server responds with the Section Rendering API result. You can make add-to-cart feel instant by rendering the cart drawer optimistically with data already available on the product page.

The approach: immediately insert the added item into the cart drawer using product data from the PDP (title, variant, price, image, quantity), then reconcile when the server response arrives. This decouples perceived UI responsiveness from server processing time.

function addToCart(variantId, quantity) {
const product = getProductDataFromPage();

// Immediately render optimistic cart item
renderOptimisticCartItem({
title: product.title,
variant: product.selectedVariant,
price: product.price,
image: product.featuredImage,
quantity: quantity,
});
openCartDrawer();

// Fetch the real server-rendered cart sections
fetch("/cart/add.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [{ id: variantId, quantity }],
sections: "cart-drawer,cart-icon-bubble",
}),
})
.then((response) => response.json())
.then((data) => {
// Replace optimistic content with server-rendered HTML
replaceCartSections(data.sections);
});
}

The server response reconciles any differences, such as discount pricing, stock changes, or cart-level promotions that only the server knows about.

Anchor to Bundled section rendering with the Cart APIBundled section rendering with the Cart API

The Cart API supports bundled section rendering, so that you can update multiple sections after cart modifications:

fetch("/cart/add.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [{ id: 39072856, quantity: 1 }],
sections: "cart-drawer,cart-icon-bubble,cart-footer",
}),
})
.then((response) => response.json())
.then((data) => {
// data.items contains the cart data
// data.sections["cart-drawer"] contains rendered cart drawer HTML
// data.sections["cart-icon-bubble"] contains rendered cart icon HTML
// and so on
});

Anchor to Use locale-aware URLsUse locale-aware URLs

When building Section Rendering API requests, use locale-aware URLs to maintain the customer's selected language and region:

// Good: use window.Shopify.routes.root for locale awareness
fetch(window.Shopify.routes.root + "?sections=header").then((response) =>
response.json()
);

// Good: or use the current page context
fetch(window.location.pathname + "?sections=product-info").then((response) =>
response.json()
);

Anchor to Single section request (alternative)Single section request (alternative)

For a single section, you can use the section_id parameter. It returns HTML directly instead of JSON:

// Returns HTML directly, not JSON. `routes.root` keeps the request locale-aware.
fetch(`${window.Shopify.routes.root}?section_id=header`)
.then((response) => response.text())
.then((html) => {
document.querySelector("#header").innerHTML = html;
});

This returns a 404 if the section doesn't exist. The 404 applies only to the section_id path. A nonexistent section ID inside ?sections= is null in an otherwise successful 200 response, so requesting several sections at once never fails outright because of one bad ID.

Caution

<script> tags in the returned HTML don't execute when you insert them with innerHTML, DOMParser, or replaceWith. You must bind any behavior that the section needs outside the replaced markup, either by delegating events from an ancestor that's never replaced, or by wrapping the section in a custom element and re-initializing in connectedCallback().


Update product details when a customer selects a different variant:

sections/product-info.liquid

<div class="product-info" data-section-id="{{ section.id }}">
<h1>{{ product.title }}</h1>
<div class="price">{{ product.selected_or_first_available_variant.price | money }}</div>
<div class="inventory">
{% if product.selected_or_first_available_variant.available %}
In stock
{% else %}
Out of stock
{% endif %}
</div>

{%- comment -%} Option picker {%- endcomment -%}
{%- for option in product.options_with_values -%}
<fieldset>
<legend>{{ option.name }}</legend>
{%- for option_value in option.values -%}
<label>
{%- comment -%}
Emit `checked` from `option_value.selected`. The JS rebuilds `option_values`
from the checked inputs, so a picker that comes back with nothing checked
would drop every option the customer didn't just click.
{%- endcomment -%}
<input
type="radio"
name="{{ option.name }}"
value="{{ option_value | escape }}"
data-option-value-id="{{ option_value.id }}"
{% if option_value.selected %}checked{% endif %}
/>
{{ option_value }}
</label>
{%- endfor -%}
</fieldset>
{%- endfor -%}
</div>

<script>
{%- comment -%}
Delegate from `document`, which is never replaced. Listeners bound directly
to the inputs would be lost the first time `.product-info` is swapped out,
and the script in the fetched section doesn't execute to rebind them.
{%- endcomment -%}
document.addEventListener('change', (event) => {
if (!event.target.matches('.product-info input[data-option-value-id]')) return;
updateProduct(event.target.closest('.product-info'));
});

function updateProduct(container) {
const sectionId = container.dataset.sectionId;
const selectedOptions = Array.from(
container.querySelectorAll('input[data-option-value-id]:checked')
).map(input => input.dataset.optionValueId);

const params = `section_id=${sectionId}&option_values=${selectedOptions.join(',')}`;

fetch(`${window.location.pathname}?${params}`)
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newContent = doc.querySelector('.product-info');

container.replaceWith(newContent);
});
}
</script>

Scope every query to the section container. A bare document.querySelectorAll('input[name]') also matches search, newsletter, and quantity inputs elsewhere on the page.

For a component-based alternative, see how Dawn's variant-selects custom element binds its change listener in connectedCallback(), so the listener re-attaches automatically each time the element is replaced.

In a ?sections= request, sections that fail to render or don't exist come back as null inside a 200 response. Always account for this:

fetch("/?sections=header,footer,missing-section")
.then((response) => response.json())
.then((data) => {
if (data.header) {
// Update header
}
if (data.footer) {
// Update footer
}
if (!data["missing-section"]) {
console.error("Section failed to render or doesn't exist");
}
});

  • Browser DevTools Network tab: Verify that only sections are fetched, not full pages.
  • Compare sizes: Check the network payload. The section response should be smaller than a full page.
  • User experience: Interactions should feel instant with no page flash.


Was this page helpful?