Shopify Liquid Partials: Swap Main Content Without Reloading Header and Footer
Merchants feel it before developers name it. Every full page load re-renders the header, re-runs footer scripts, and re-initialises app snippets that live in global chrome. The product grid or policy page in the middle changes, but the browser still pays the cost of the whole document.
Shopify's Liquid July '26 developer preview adds {% partial %} and @shopify/partial-rendering. The use case we cared about first was not micro-updates for fun. It was persistent shell navigation: header and footer load once per visit, and in-theme links refresh only the main content that actually changes.
That aligns with the browser's Declarative Partial Updates direction (named server HTML regions, swapped in place). It is not a mandate to rebuild the theme as a JavaScript app. It is progressive enhancement on top of normal Liquid, the same foundation we argue for in Online Store 2.0 and high-performance theme work.
Introduction
For years, the honest answer to "can we keep the nav and only load new content?" on Shopify was a custom stack: intercept clicks, fetch HTML, guess which nodes to replace, and hope Section Rendering IDs still match after a merchant edits the theme. Every agency theme reinvents the same failure modes: double-fired analytics, cart counts that drift, focus lost after swap, and header scripts that assume a full DOMContentLoaded on each URL.
The July '26 {% partial %} preview does not magically implement routing. It does give you a stable name for the main column in layout Liquid, and a supported fetch / apply / refresh path so the server returns HTML for that name only. That is the missing piece for shell navigation if you are willing to own history, title updates, and accessibility yourself.
We implemented this on block-first skeleton theme work: page-content around content_for_layout, header and footer groups untouched on in-theme link navigation, optional nested refresh for cart chrome when line items change. This article walks through that architecture first. Collection sort partials and multi-region fetches on a single template are the same tool with a smaller boundary, not the headline win.
If you are evaluating preview features for a live merchant roadmap, treat soft nav as enhancement, not dependency. Checkout, account, and external links should remain full loads until you have fallbacks and monitoring in place.
Why we wrapped only the main column
Classic Shopify themes already separate layout from template output via content_for_layout. Partials make that boundary addressable from JavaScript.
On first load, the buyer gets a normal full HTML response: header group, <main>, footer group, analytics, cart bootstrap, the lot.
On subsequent in-theme navigation (when you opt in with soft nav):
- Header and footer stay in the DOM. You do not fetch them again unless you choose to.
- You
partials.fetch('page-content', { url })for the destination URL. - You
partials.apply(update)so only the named region inside<main>swaps.
Less flicker, less repeated work in global chrome, and fewer chances for app scripts in the header to fight each other on every click.
If your theme is already brittle under app load, read common theme mistakes that kill conversion before you add interceptors. Partials fix where HTML lands; they do not fix how much JavaScript you shipped in the shell.
Layout shape on the preview
The preview also ships {% block %} for block-first templates. For soft navigation, the important tag is {% partial %}.
In layout/theme.liquid, the pattern we used looked conceptually like this:
<body>
{% sections 'header-group' %}
<main id="MainContent">
{% partial 'page-content' %}
{{ content_for_layout }}
{% endpartial %}
</main>
{% sections 'footer-group' %}
</body>
page-content is the contract name. Liquid renders it on first paint. JavaScript uses the same string when the buyer follows an in-theme link.
Preview requirements: enable Liquid July '26 on a development store. Outside preview, Liquid may reject the tag. Always ship a fallback: unmodified links, full page loads, no dependency on soft nav for checkout or core paths.
Soft navigation flow (what you still own)
Shopify does not ship automatic SPA-style link handling. Partials give you the swap mechanism, not the router. Our storefront flow:
- Leave header and footer outside the partial you replace on navigation.
- Intercept same-origin in-theme links (we used a
data-partial-navconvention),preventDefault,history.pushStatewith the destination URL. partials.fetch('page-content', { url })thenpartials.apply(update).- On
popstate,partials.refresh('page-content')so back and forward matchwindow.location. - You still implement document title, meta where relevant, analytics page views, focus management, and any active nav state that must track the new URL (often a follow-up partial or a small DOM update, not a full header re-fetch).
First visit, no-JS buyers, and crawlers keep full document loads. That is the line between soft nav and a fragile app shell.
JavaScript: fetch, apply, and refresh
@shopify/partial-rendering centres on three calls for this pattern:
| API | Role in shell navigation |
|---|---|
partials.fetch('page-content', { url }) | Request fresh main-column HTML for the destination URL. Build url from window.location or Liquid routes, not hardcoded paths, so markets and locales keep working. |
partials.apply(update) | Replace the live page-content region. Optional View Transitions wrapper where supported. |
partials.refresh('page-content') | Re-sync main content with the current URL after back/forward without rebuilding the request yourself. |
Details that matter in production:
- Pass
AbortSignalso a slow click cannot overwrite a newer navigation. - Set
aria-busyon<main>(or the partial root) while the fetch runs; use a live region when the swap should be announced. - After
apply(), re-query nodes insidepage-contentbefore re-binding animations or listeners. The old subtree is gone.
What we deliberately do not re-fetch on route change
The wrong instinct is to request every named region in the layout response on each navigation, including header cart markup. That recreates full reload costs in smaller pieces.
Persistent shell:
- Header group, footer group, and global snippets: first load only for route changes.
page-content: every soft navigation.
Targeted exceptions (same page or event-driven, not every link):
- A nested
header-cartpartial can refresh onshopify:cart:lines-updateso the badge stays accurate without reloading the whole header or the main column. - Collection sort or filter can fetch
product-grid,product-count, andtoolbartogether on that template without touching layout chrome. That is the same partial machinery, different boundary.
Cart mutations stay on Shopify.actions and shopify:cart:* events. Partials re-render markup; they do not replace Shopify's event model. That separation matters on stores where checkout abandonment already traces back to script churn in global chrome.
Keep {% sections 'header-group' %} as a direct child of <body> for theme editor discovery, not buried inside block wrappers that confuse the editor.
Not partial hydration (and not a free Turbo clone)
Developers often mislabel this as partial hydration. That term usually means attaching JavaScript to islands while most HTML stays static. Here, the server still owns HTML generation; the client replaces a named region with fresh server output. apply() preserves focus, selection, and form values where the runtime allows.
Lower-level Declarative Partial Updates platform APIs (streaming markers, <template for>, insertion APIs) sit underneath this story. For theme work, @shopify/partial-rendering is the practical layer.
Theme editor: the sharp edge
Partials are a storefront preview feature. request.design_mode follows a different path. Unconditional partial wrappers can yield Unknown tag 'partial' and break header group mounting in the theme editor.
What worked:
{% if request.design_mode %}branches: plain Liquid in the editor, partial wrappers on the storefront.- Disable soft-navigation scripts in design mode so they do not fight editor clicks.
If your team lives in the theme editor daily, budget time for this split before you promise merchants shell navigation on production timelines.
Checklist before you ship shell navigation
- Name parity:
{% partial 'page-content' %}andpartials.fetch('page-content')must match exactly. - Chrome outside the partial: header and footer are not in the region you replace on link navigation.
- Fallback: failed fetch or missing runtime falls back to
window.locationor normal link behaviour. - Stale requests: abort or ignore superseded fetches.
- Loading and announcements:
aria-busy, live regions where swaps are silent. - Editor vs storefront:
request.design_modebranches for tags and nav scripts. - Ownership: title, analytics, focus, and nav active state have an explicit owner on each navigation.
Conclusion
Liquid partials gave us a documented name for the boundary merchants already imagine: global chrome stays put, the main column updates. That is the game-changing day-to-day story on heavy DTC themes, more than yet another Ajax patch for a sort dropdown.
Start on the preview with page-content and one class of in-theme links before you expand to collection partials or nested cart regions. If soft nav intersects checkout extensions, app conflicts, or a catalogue model that already fights structured data and filters, scope it inside a broader theme roadmap.
For layout boundaries and partial strategy on an established store, see our Shopify theme development capability or book a stack assessment to map preview features against what your stack can maintain long term.



