Tutorial
Build a Shopify quick view modal without an app
Quick view apps add scripts you do not control. The Section Rendering API does it natively in about 40 lines, reusing markup your theme already has.
Bas Lefeber
Founder, learnshopify.dev · August 24, 2026 · 5 min read
Ask any AI assistant to build a quick view for a Shopify collection page and you will get the same answer nearly every time: fetch /products/handle.js, then build the modal's HTML from the JSON in a template literal. It works. You can ship it this afternoon. And you have just created the bug that will find you in four months, when a merchant asks why the quick view still shows the old badge layout that you removed from the product page in March.
The problem is not the fetch. The problem is that your product markup now lives in two places: once in Liquid, where the theme renders it, and once in a JavaScript string, where the modal renders it. Two copies of the same UI always drift. The one nobody looks at drifts faster.
Shopify has a native answer that most theme developers never reach for, and it removes the second copy entirely: the Section Rendering API.
TL;DR
Append ?section_id=quick-view to any product URL and Shopify returns that section's rendered HTML. Fetch it as text, drop it into a <dialog>, and you have a quick view that reuses the theme's own markup. No app, no second copy of your product UI, roughly 40 lines total.
How the Section Rendering API works
Every Shopify page can render any one of its sections in isolation. Add ?section_id=<id> to a URL and instead of the full page you get back just that section's HTML, rendered server-side by Liquid with the page's normal context. On a product URL, that context includes the product object.
# The whole product pagecurl https://shop.myshopify.com/products/ethiopian-yirgacheffe # Just the quick-view section, rendered with that product in scopecurl "https://shop.myshopify.com/products/ethiopian-yirgacheffe?section_id=quick-view"The response is HTML, not JSON. That trips people up on the first attempt, because the reflex with any fetch is to call .json() on it. Read it with .text() and set it as innerHTML.
This is the same mechanism behind infinite scroll, live cart drawers, and filtered collection grids that update without a reload. Learning it once pays for itself across all of them.
Step 1: the section
Create sections/quick-view.liquid. It never appears on a page through a template; it exists purely to be fetched. Keep it lean, because the point of a quick view is to be faster than the product page, not to be the product page in a box.
{% comment %} Rendered standalone via /products/<handle>?section_id=quick-view{% endcomment %}<div class="quick-view"> <img src="{{ product.featured_image | image_url: width: 640 }}" alt="{{ product.featured_image.alt | escape }}" width="640" height="640" loading="lazy" > <h2 class="quick-view__title">{{ product.title }}</h2> <p class="quick-view__price">{{ product.price | money }}</p> {% form 'product', product %} <select name="id"> {% for variant in product.variants %} <option value="{{ variant.id }}" {% unless variant.available %}disabled{% endunless %} > {{ variant.title }} ({{ variant.price | money }}) </option> {% endfor %} </select> <button type="submit" {% unless product.available %}disabled{% endunless %}> {{ 'products.product.add_to_cart' | t }} </button> {% endform %}</div> {% schema %}{ "name": "Quick view"}{% endschema %}The t filter needs a real locale entry
Theme Check rejects {{ 'products.product.add_to_cart' | t }} if that key does not exist in locales/en.default.json. Most themes already have it. If yours does not, add it before you push, and do not reach for the default: argument as a shortcut, because validators treat that as a reserved-name error.
Two details in that section are doing real work. The {% form 'product', product %} tag generates Shopify's standard product form, which means add-to-cart from the modal goes through the same path as the product page rather than a hand-rolled fetch. And the money filter renders the price in the shop's configured format instead of you formatting cents by hand.
Step 2: the trigger
On each product card, add a button carrying the product URL. Use one delegated listener on a container rather than binding per card, so cards added later (by filtering, by pagination, by infinite scroll) keep working without rebinding.
<button type="button" class="quick-view-trigger" data-quick-view-url="{{ product.url }}"> Quick view</button>Step 3: fetch and open
Use the native <dialog> element. It gives you focus trapping, Escape to close, and a backdrop, all of which you would otherwise write badly by hand. Accessibility is where most homemade modals fall down, and this is the cheapest possible fix.
<dialog id="quick-view-modal"> <button type="button" data-close aria-label="Close">×</button> <div id="quick-view-body"></div></dialog>const modal = document.getElementById("quick-view-modal");const body = document.getElementById("quick-view-body"); document.addEventListener("click", async (event) => { const trigger = event.target.closest("[data-quick-view-url]"); if (!trigger) return; body.innerHTML = "<p>Loading…</p>"; modal.showModal(); try { const url = trigger.dataset.quickViewUrl + "?section_id=quick-view"; const res = await fetch(url); if (!res.ok) throw new Error(res.status); // HTML, not JSON. This is the step people get wrong first. body.innerHTML = await res.text(); } catch { // Never strand the shopper in an empty modal. body.innerHTML = '<p>Could not load this product. <a href="' + trigger.dataset.quickViewUrl + '">Open the full page</a>.</p>'; }}); modal.addEventListener("click", (event) => { // Close on backdrop click, and on the close button. if (event.target === modal || event.target.closest("[data-close]")) { modal.close(); }});Note the order: the modal opens before the fetch resolves, showing a loading state. Opening after the response feels broken on a slow connection, because the shopper clicks and nothing happens for a second. Opening immediately makes the same second feel like loading rather than like a dead button.
The gotchas that cost an afternoon
| Symptom | Cause | Fix |
|---|---|---|
| Response is the entire page | The section id does not match a file in sections/ | The id is the filename without .liquid. A typo silently returns the full page. |
| Modal shows [object Object] | Used .json() instead of .text() | The Section Rendering API returns HTML. |
| Variant picker does not react | Injected markup never ran its JavaScript | Re-initialise after injecting, or use custom elements, which upgrade automatically when inserted. |
| Add to cart does nothing | The theme's cart JS binds on page load only | Use a delegated listener for cart submits too, for the same reason the trigger is delegated. |
Custom elements save you the re-init dance
Anything inside the fetched section that is a custom element upgrades itself the moment it lands in the DOM. That is the cleanest way to make injected markup interactive without hunting for a re-init hook, and it is why modern Shopify themes lean on them so heavily.
Why not just install an app
Sometimes an app is the right call, and a developer who reflexively hand-builds everything is not being senior, they are being expensive. But quick view is a poor candidate for one. A quick view app injects a script on every page to serve a feature used on one, renders product markup it templated itself (so it drifts from your theme in exactly the way described at the top), and adds a vendor between your merchant and their own add-to-cart path.
The version above adds one section file, one small script, and no third party. It renders whatever your product page renders because it literally is your product page's markup. When you restyle the product card in six months, the quick view restyles itself.
Learn this properly · free lesson
Quick-view modal with the Section Rendering API
Build this against a live storefront emulator with a working Section Rendering API. You write the fetch, the modal fills with real section HTML, and the validator checks you used .text() and not .json().
Try this lesson — freeFrequently asked questions
How do I build a quick view on Shopify without an app?
Create a dedicated section such as sections/quick-view.liquid, then fetch any product URL with ?section_id=quick-view appended. Shopify returns that section's rendered HTML, which you read with .text() and inject into a dialog element. No app is required.
What is the Shopify Section Rendering API?
It is a built-in feature that renders a single section of a page in isolation. Appending ?section_id=<id> to a page URL returns just that section's server-rendered HTML with the page's normal Liquid context, including the product object on a product URL.
Does the Section Rendering API return JSON?
No. It returns HTML. Read the response with .text() rather than .json(), then set it as innerHTML on your container.
Why does my quick view return the whole product page?
The section id did not match a file in your sections directory. The id is the filename without the .liquid extension, and a typo causes Shopify to return the full page rather than an error.
Why does the variant picker stop working inside a quick view modal?
Injected HTML does not run any JavaScript that was bound on page load. Either re-initialise the picker after injecting the markup, or build it as a custom element, which upgrades automatically when inserted into the DOM.
Start free
Ready to become a Shopify developer?
You just read how it works. Now write it yourself: real tickets from a live store, in an editor where the storefront updates as you type. Module 1 is free, no card.
Free · No credit card · Your first win in minutes
About the author
Bas Lefeber, Founder, learnshopify.dev
Bas builds learnshopify.dev, where developers learn production-grade Shopify theme development against a live storefront. He writes about Liquid, theme architecture, and the parts of the job that still matter now that AI writes the code.
Keep going in the curriculum


