Titouan Mathis

CTO at Studio Meta and ikko

Shopify Section Rendering API example

29/07/2026 in #shopify #section-rendering-api #progressive-enhancement

The 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&sections=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 sections option, not the URL. The component appends them to the request as ?sections=… when JavaScript runs, so the element's href stays a clean, fully rendered page for the no-JS fallback.
  • The Section Rendering JSON ({ [id]: html }) is unwrapped by its default response option, and each shopify-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-element response boilerplate.

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.

MDN Reference

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) => URL

The URL interface is used to parse, construct, normalize, and encode URL.

MDN Reference

URL class is a global reference for import { URL } from 'node:url' https://nodejs.org/api/url.html#the-whatwg-url-api

@sincev10.0.0
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.

MDN Reference

URL class is a global reference for import { URL } from 'node:url' https://nodejs.org/api/url.html#the-whatwg-url-api

@sincev10.0.0
URL
? input: URLinput.URL.href: string

The href property of the URL interface is a string containing the whole URL.

MDN Reference

href
: input: Requestinput.Request.url: string

The url read-only property of the Request interface contains the URL of the request.

MDN Reference

url
,
'http://localhost', ); if (const url: URLurl.URL.searchParams: URLSearchParams

The searchParams read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL.

MDN Reference

searchParams
.URLSearchParams.has(name: string, value?: string): boolean

The has() method of the URLSearchParams interface returns a boolean value that indicates whether the specified parameter is in the search parameters.

MDN Reference

has
('sections')) {
const const sorted: anysorted = /* … sort products by ?sort_by … */ products; return new var Response: new (body?: BodyInit | null, init?: ResponseInit) => Response

The Response interface of the Fetch API represents the response to a request.

MDN Reference

Response
(
var JSON: JSON

An 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.

@paramvalue A JavaScript value, usually an object or array, to be converted.@paramreplacer A function that transforms the results.@paramspace Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.@throws{TypeError} If a circular reference or a BigInt value is found.
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.