Shopify Section Rendering API example
29/07/2026 in #shopify #section-rendering-api #progressive-enhancementThe series
Part 2 of 7 of the series Building a Shopify storefront with @studiometa/ui.
Shopify's Section Rendering API lets you ask the server for the rendered HTML of specific theme sections and drop them into the current page, without a full reload. It covers AJAX navigation, filtering and cart updates, because the HTML still comes from your Liquid sections. This post is a concrete example of consuming it with @studiometa/ui, a component library built on @studiometa/js-toolkit.
Here is the result. Sorting swaps two sections, the product grid and the results count, in place:
The FetchShopifySection component consumes the Section Rendering API from attributes on the link, with no request code.
Table of content
What the Section Rendering API returns
Add a comma-separated sections parameter (up to five) to any storefront URL, and Shopify answers with a JSON object: one key per requested section, each value the section's rendered HTML, or null if it failed to render.
// GET /collections/all?sort_by=price-ascending§ions=main-collection-product-grid,collection-results-count
{
"main-collection-product-grid": "<div id=\"shopify-section-main-collection-product-grid\" class=\"shopify-section\">…</div>",
"collection-results-count": "<div id=\"shopify-section-collection-results-count\" class=\"shopify-section\">…</div>"
}
Every section is wrapped in a <div id="shopify-section-{id}"> element, both on the page and inside the API response, so the ids already line up between what you have and what you get back.
Wiring it with FetchShopifySection
FetchShopifySection is a thin wrapper around Fetch built for exactly this API. It intercepts a link click, fetches its href, and swaps elements from the response into the page by matching id. Two things are handled for you:
- The section IDs go in the
sectionsoption, not the URL. The component appends them to the request as?sections=…when JavaScript runs, so the element'shrefstays a clean, fully rendered page for the no-JS fallback. - The Section Rendering JSON (
{ [id]: html }) is unwrapped by its defaultresponseoption, and eachshopify-section-*wrapper is swapped by the inherited[id]selector. The JSON keys are ignored, so there is never a key/id mismatch, and there is no per-elementresponseboilerplate.
In a collection template it looks like this:
<a
href="{{ collection.url }}?sort_by=price-ascending"
data-component="FetchShopifySection"
data-option-sections="main-collection-product-grid,collection-results-count"
data-option-history>
Price, low to high
</a>
{% comment %} Shopify renders these wrappers; FetchShopifySection swaps them by id. {% endcomment %}
<div id="shopify-section-main-collection-product-grid">…</div>
<div id="shopify-section-collection-results-count">…</div>
Register the components once:
import { import registerComponentsregisterComponents } from '@studiometa/js-toolkit';
import { class Action<T extends BaseProps = BaseProps>Action class.
Action, import FetchShopifySectionFetchShopifySection, class Transition<T extends BaseProps = BaseProps>Transition class.
Transition } from '@studiometa/ui';
import registerComponentsregisterComponents(class Action<T extends BaseProps = BaseProps>Action class.
Action, import FetchShopifySectionFetchShopifySection, class Transition<T extends BaseProps = BaseProps>Transition class.
Transition);
Because it extends Fetch, it inherits every option. Two are worth knowing. View Transitions are on by default when the browser supports them; disable them with data-option-no-view-transition. And data-option-history is safe to enable here: FetchShopifySection strips the sections parameter from the URL it pushes, so the address bar keeps the shareable ?sort_by=… page while the raw section endpoint never appears in history. That means the sort survives a refresh and a shared link, from the enhanced path as well as the no-JS one.
The loader is the one bit of glue: Action listens for the bubbling fetch-before and fetch-after events and drives a Transition on the #loader element. Because Fetch events bubble, the Action can sit on any parent.
How the demo simulates it
The playground has no Shopify backend, so the demo's script mocks window.fetch: when it sees a sections parameter it sorts an in-memory product list and returns the two sections as JSON, each wrapped in its shopify-section-* element, exactly as Shopify would. The demo uses short section ids (product-grid, results-count) for brevity; on a real store these are your theme's section filenames (for example main-collection-product-grid). The names are free to differ because FetchShopifySection swaps by each shopify-section-* element's id and ignores the response's JSON keys entirely.
module window
var window: Window & typeof globalThis
The window property of a Window object points to the window object itself.
window.function fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>fetch = async (input: RequestInfo | URLinput, init: RequestInit | undefinedinit) => {
const const url: URLurl = new var URL: new (url: string | URL, base?: string | URL) => URLThe URL interface is used to parse, construct, normalize, and encode URL.
URL class is a global reference for import { URL } from 'node:url'
https://nodejs.org/api/url.html#the-whatwg-url-api
URL(
typeof input: RequestInfo | URLinput === 'string' ? input: stringinput : input: Request | URLinput instanceof var URL: {
new (url: string | URL, base?: string | URL): URL;
prototype: URL;
canParse(url: string | URL, base?: string | URL): boolean;
createObjectURL(obj: Blob | MediaSource): string;
parse(url: string | URL, base?: string | URL): URL | null;
revokeObjectURL(url: string): void;
}
The URL interface is used to parse, construct, normalize, and encode URL.
URL class is a global reference for import { URL } from 'node:url'
https://nodejs.org/api/url.html#the-whatwg-url-api
URL ? input: URLinput.URL.href: stringThe href property of the URL interface is a string containing the whole URL.
href : input: Requestinput.Request.url: stringThe url read-only property of the Request interface contains the URL of the request.
url,
'http://localhost',
);
if (const url: URLurl.URL.searchParams: URLSearchParamsThe searchParams read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL.
searchParams.URLSearchParams.has(name: string, value?: string): booleanThe has() method of the URLSearchParams interface returns a boolean value that indicates whether the specified parameter is in the search parameters.
has('sections')) {
const const sorted: anysorted = /* … sort products by ?sort_by … */ products;
return new var Response: new (body?: BodyInit | null, init?: ResponseInit) => ResponseThe Response interface of the Fetch API represents the response to a request.
Response(
var JSON: JSONAn intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.
JSON.JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)Converts a JavaScript value to a JavaScript Object Notation (JSON) string.
stringify({ 'product-grid': grid(const sorted: anysorted), 'results-count': count(const sorted: anysorted) }),
{ ResponseInit.headers?: HeadersInit | undefinedheaders: { 'content-type': 'application/json' } },
);
}
return realFetch(input: RequestInfo | URLinput, init: RequestInit | undefinedinit);
};
Swap the mock for your store: delete the window.fetch block. The links already declare their sections and FetchShopifySection parses the response, so the same markup now hits the real endpoint. Use your theme's real section ids (for example main-collection-product-grid). You can open the demo in the playground and edit it live.
When the parameters come from user input
A single link carries its own sections option. When the value comes from the customer, say a sort <select>, facet checkboxes or a search field, put a <form method="get"> around the inputs and let FetchShopifySection build the URL from the form data — the same sections option applies, no hidden input needed:
<form id="collection-sort" action="{{ collection.url }}" method="get"
data-component="FetchShopifySection"
data-option-sections="main-collection-product-grid,collection-results-count"
data-option-history>
<select name="sort_by" data-component="Action" data-on:change="FetchShopifySection(#collection-sort) -> target.$el.requestSubmit()">
<option value="manual">Featured</option>
<option value="price-ascending">Price, low to high</option>
</select>
</form>
That is the entry point to AJAX collection filtering, where the same technique carries a full set of facets and keeps the URL shareable.
The {% partial %} preview (Liquid July '26)
Developer preview
The following uses Shopify's partial rendering API, part of the Liquid July '26 developer preview. It relies on the optional @shopify/partial-rendering package. Treat it as forward-looking until it ships as stable.
The Section Rendering API is stable and works today. Shopify is also previewing a newer primitive: you mark a region with the {% partial %} tag and refresh it by name, and the update preserves focus, text selection, form values and scroll position out of the box. @studiometa/ui ships a FetchShopifyPartial component for it, which extends Fetch.
{% partial 'product-grid' %}
<ul id="product-grid">
{% for product in collection.products %}
{% render 'product-card', product: product %}
{% endfor %}
</ul>
{% endpartial %}
<a
href="{{ collection.url }}?sort_by=price-ascending"
data-component="FetchShopifyPartial"
data-option-partials="product-grid,product-count"
data-option-history>
Price, low to high
</a>
import { import registerComponentsregisterComponents } from '@studiometa/js-toolkit';
import { import FetchShopifyPartialFetchShopifyPartial } from '@studiometa/ui';
import registerComponentsregisterComponents(import FetchShopifyPartialFetchShopifyPartial);
FetchShopifyPartial falls back to Fetch automatically, which lowers the risk of adopting the preview early. It uses the partial rendering path only when the partials option lists at least one name and the preview package resolves and the request is a plain GET. Otherwise (no package installed, no partials configured, a POST form, custom headers) it behaves exactly like the base Fetch and does the id-based swap. So the same markup degrades cleanly on a store that is not on the preview.
Which one to reach for
Use the Section Rendering API today: it is stable, needs no extra package, and the Fetch recipe above is production-ready. Reach for {% partial %} and FetchShopifyPartial when you want the ergonomics and automatic state preservation of the newer primitive and you are on the July '26 preview. Both share the same component model, so moving between them is a matter of the component name and the option.
The sort link above swaps two server-rendered sections, the grid and the count, with no request code, while leaving the ?sections= URL out of the address bar so a refresh never lands on raw section JSON. For the setup, see the pillar.
Next article
AJAX collection filtering turns that link into a full faceted form.