LUCENTCOMMERCEGET A FREE STORE AUDITFREE AUDIT

THEME · TECHNICAL · PERFORMANCE · 15 JULY 2026 · 8 MIN READ

Progressive enhancement in a Shopify theme

The argument has nothing to do with users who disable JavaScript. It is about the ordinary minutes when a script is late, blocked or broken — and whether your add to cart still works.

A Liquid template open beside the section it renders

Because on a storefront, JavaScript failing is not a hypothetical — it is a Tuesday. A third-party app script throws, a CDN is slow, a network drops mid-load, or the page simply has not finished hydrating when the customer taps. If your add to cart is a button that only works once a bundle has arrived and run, every one of those is a lost order. Shopify’s Liquid form tags already generate working HTML forms that post to real endpoints, so the baseline is free; the work is in not throwing it away.

IN SHORT

  • Progressive enhancement on a storefront is about resilience to script failure, not about users who turn JavaScript off.
  • Shopify’s `{% form %}` tag “Generates an HTML `<form>` tag, including any required `<input>` tags to submit the form to a specific endpoint” — the product form posts to `/cart/add` with no JavaScript involved.
  • The tag supports fifteen form types, including `product`, `cart`, `contact`, `customer_login` and `localization`, so most core flows have a no-JavaScript path built in.
  • Storefront filtering is expressed in URL query parameters such as `filter.p.product_type=shoes`, which means filters work as plain links before any script enhances them.
  • The Section Rendering API returns rendered HTML for up to five sections per request and respects the same query parameters as the full page, so the enhanced path and the plain path can share one URL.
  • Sections that fail to render are “returned as `null` in the JSON response”, which is the error case your enhancement layer has to handle.
  • The test that matters is not disabling JavaScript — it is blocking your app domains and throttling the network, because that is what actually happens to customers.

Nobody turns JavaScript off. That was never the argument

The case for progressive enhancement is regularly dismissed by pointing out how few people browse with JavaScript disabled, which is true and beside the point. The population that matters is much larger and entirely accidental:

  • Customers on a weak connection, where a 180KB bundle takes seconds to arrive and the page looks finished long before it works.
  • Customers whose browser ran your script and then an app’s script threw an uncaught error before yours initialised — one broken third-party tag can take an entire page’s interactivity with it.
  • Customers behind corporate proxies, aggressive content blockers or ISP-level filtering, where some domains load and others silently do not.
  • Customers who tap during the gap between the page painting and the script binding its handlers. This gap exists on every site; on a slow phone it is long enough to notice.

What Shopify gives you before you write a line of JavaScript

The baseline is already there. Shopify’s documentation describes the {% form %} tag as generating “an HTML <form> tag, including any required <input> tags to submit the form to a specific endpoint”, and the generated product form is exactly what you would hope: <form method="post" action="/cart/add" ... class="shopify-product-form"> with the hidden fields already populated. Submitted with no JavaScript at all, that adds the item to the cart and navigates to the cart page.

It is not just the product form. The tag supports fifteen types — product, cart, contact, create_customer, customer_login, customer_address, localization, currency and the password and comment forms among them — which between them cover buying, account management, market switching and contact. Every one of those is a full-page-reload flow that works, today, without a script.

Navigation follows the same pattern. Storefront filtering is expressed entirely in URL query parameters — the documented format is filter.p. for product-level and filter.v. for variant-level filters, as in filter.p.product_type=shoes — with multiple values either comma-separated or repeated. A filter is therefore a link. Search is a GET form. Pagination is a query parameter. The whole browse-and-buy path is expressible in HTML that has worked since 1997.

Which means the usual failure is not an absent baseline. It is a theme that replaced the working form with a <div> and a click handler, or intercepted the filter link with preventDefault and never provided a URL for the resulting state.

The enhancement layer, and the API built for it

The Section Rendering API is the piece that makes this practical rather than principled. Shopify documents it as a way to “request the HTML markup for theme sections using an AJAX request”: pass sections with “a comma-separated list of IDs or an array” — up to five — and you get back “a JSON object that includes pairs for each section ID and its corresponding rendered HTML”, or pass section_id for a single section and receive HTML directly.

The important line for progressive enhancement is this one: “Any query parameters that are respected when rendering the full page, such as q or page, are also respected when sections are rendered.” The same URL serves both audiences. A filter link with filter.p.product_type=shoes is a working navigation for a browser with no script; intercepted, the same URL with a sections parameter returns just the product grid and the facet panel, rendered by the same Liquid that would have rendered the page. There is no second implementation of the filtering logic to keep in step with the first, which is the reason most “we will add a no-JS fallback later” plans never happen.

Two documented behaviours belong in your enhancement code. Sections that fail to render “are returned as null in the JSON response”, and a single-section request for an ID that does not exist returns 404. Both are cases where the correct response is to stop enhancing and let the browser do what it was going to do anyway — follow the link, submit the form. An enhancement layer whose error handling is a console.error has converted a slow page into a broken one.

One rule, and most of it follows

The URL is the state. If the customer can reach a meaningful view of your store, that view has a URL, and requesting that URL directly produces it. Hold to that and most progressive enhancement questions answer themselves.

Filters live in the URL, so they survive a reload, work as a shared link, and respond to the back button. A cart drawer is an enhancement over /cart, which still exists and still works. A quick-view modal is an enhancement over the product page, and the thing you intercepted was a real link to it. Sorting is a query parameter. Pagination is a real page.

The rule also catches the subtler regressions. A product page where selecting a variant only updates JavaScript state produces a URL that no longer describes what the customer is looking at — so the shared link goes to the wrong variant and the browser’s back button does nothing they expect. Keeping ?variant= in the URL is a one-line habit that fixes a class of support tickets nobody connects to the theme.

And it is the SEO position too, arrived at from a different direction. A filtered view worth indexing needs a URL; a filtered view not worth indexing needs a URL you can exclude. State that lives only in memory is neither.

Where we would not bother

Progressive enhancement does not mean building everything twice, and pretending otherwise is how the idea gets a reputation for being expensive. Some interfaces genuinely cannot exist without JavaScript: a multi-step configurator with live pricing, a made-to-measure tool, a 3D viewer, a store locator with a map.

For those, the honest baseline is not a non-JavaScript equivalent — it is a non-JavaScript *route to the same outcome*. A configurator that cannot load should render a message and a link to a contact form or a phone number, not an empty box. A locator should fall back to a plain list of addresses, which is cheap and is often what the customer wanted. The goal was never parity. It was that nobody reaches a dead end.

Equally, we would not rewrite a working theme for this. Retrofitting progressive enhancement across an entire storefront is a large project with no visible output, which is a hard thing to justify and an easy thing to abandon halfway. Apply it to what you build next, and to the two or three paths where a failure costs an order — add to cart, cart, checkout entry. That covers most of the exposure for a fraction of the work.

How to test it, since disabling JavaScript will not tell you much

Turning JavaScript off entirely is a useful five-minute sanity check and a poor simulation, because the real failures are partial. Three tests that find real problems:

  • Block one domain. In DevTools, block the request for a single app’s script and use the site. If an unrelated feature stops working, you have found a dependency nobody documented.
  • Throttle and tap early. Load a product page on a throttled connection and press add to cart the moment it is visible. If nothing happens, the button is a script waiting to load, and every slow customer is meeting the same thing.
  • Reload a state. Filter a collection, copy the URL, open it in a clean window. If you do not get the filtered view, the state is not in the URL and none of the rest applies.

The position, plainly

On Shopify, progressive enhancement is cheaper than it is on almost any other platform, because the platform hands you working forms, URL-addressable filtering and a section API designed to render fragments from the same templates. Nearly all of the cost is in a discipline rather than in code: do not replace a form with a div, do not intercept a link you have not provided a URL for, and make the enhancement layer fail back to the browser instead of failing loudly.

The commercial argument is one line. Every interaction that depends on a script is an interaction that depends on the network, on three vendors and on a race condition. Add to cart is not a good place to accept that bet — and if you want the detail on how this shapes a theme build, that is the [theme customisation](/services/build/shopify-theme-customization) conversation rather than an afterthought at QA.

Questions this raises

Why does progressive enhancement matter for ecommerce?

Because interactions that depend on JavaScript depend on the network, on third-party vendors, and on the timing of hydration. A customer on a slow connection, behind a blocker, or tapping before a script has bound its handlers, meets a page that looks ready and is not. On a storefront, that is a lost order rather than a degraded experience.

Does a Shopify theme work without JavaScript by default?

The core flows can. Shopify’s `{% form %}` tag generates real HTML forms with the required inputs — the product form posts to `/cart/add` — and supports fifteen form types covering purchase, accounts, contact and localisation. Filtering, search and pagination are URL query parameters. Themes break this when they replace forms with click handlers or intercept links without providing URLs.

What is the Section Rendering API for?

Requesting the HTML markup for theme sections over AJAX, so an enhanced interaction can update part of a page using the same Liquid that renders the full page. It takes up to five sections per request, and it respects the same query parameters the full page respects — which is what lets one URL serve both the plain navigation and the enhanced update.

How should the enhancement layer handle errors?

By getting out of the way. Shopify documents that sections which fail to render come back as `null`, and that a missing single section returns `404`. In both cases, let the browser perform the original navigation or form submission. An enhancement that swallows its own failure turns a slow page into a broken one.

Is it worth retrofitting an existing theme?

Rarely as a whole-theme project — that is a large piece of work with nothing to show for it, and those get abandoned. Retrofit the paths where failure costs money: add to cart, cart, and entry to checkout. Apply the discipline properly to everything you build from now on.

What about features that genuinely need JavaScript?

Give them a route, not a replica. A configurator or a 3D viewer cannot have a non-JavaScript equivalent, but it can render a message and a link to a contact route instead of an empty container, and a map can fall back to a list of addresses. The requirement is that nobody reaches a dead end, not that every feature exists twice.

NEXT STEP

Free store audit

A senior Shopify engineer reviews your storefront, theme performance and checkout, then sends a prioritised list of fixes.