Invental/ Writing/ Embeddable Shopify widget
Shopify — 20 · Jul · 2026 · 9 min read

A widget that survives any theme.

Shipping UI onto a store you don't control means giving up almost every assumption a normal frontend gets to make. Here's how a quiz widget from a data platform we built stays intact across legacy themes, Online Store 2.0, and everything a merchant's agency bolted on in between.

MS
Maria S.
Anna L.
20 · Jul · 2026
Building a Shopify Widget That Survives Any Theme

The hard part of an embeddable storefront widget isn't the UI — it's that you're a guest in someone else's house, and the house has 8,000 different floor plans. We learned this building a quiz widget for a pet-nutrition data platform we built: a shopper answers a few questions about their dog or cat, and the widget recommends products the merchant actually stocks, with an add-to-cart button that works. The widget itself is a fairly ordinary React quiz. What made it shippable across any Shopify theme — legacy, Online Store 2.0, whatever a merchant's agency bolted on in 2019 — was a set of constraints most in-house component libraries never have to think about.

None of the usual assumptions hold on a storefront you don't control. There's no bundler config you own, no guarantee of ES module support, no CSS namespace that's actually yours, and no telling what the theme's own JavaScript is doing to window at the moment your script tag fires. This is the write-up of how we handled each of those constraints — bundling, styling, delivery, data isolation, and checkout — for a widget that had to work the same way on a merchant's storefront whether their theme was from 2016 or 2026.


§ 01One file, no module system

A storefront isn't a build pipeline. There's no npm install, no bundler resolving your imports, and — depending on the theme — no guarantee that <script type="module"> loads in an order you can predict relative to other apps' scripts on the same page. The safest assumption is the most restrictive one: your widget is one script tag, self-contained, that runs the moment it's evaluated. No external chunks, no dynamic imports, no shared runtime with anything else on the page.

That means compiling React itself into the bundle rather than expecting a global, and outputting a single IIFE (immediately-invoked function expression) instead of an ES module. Vite's library mode does this cleanly if you tell it to:

// vite.config.ts — single self-contained IIFE, no external deps
export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, "src/main.tsx"),
      name: "QuizWidget",
      formats: ["iife"],
      fileName: () => "quiz-widget.js",
    },
  },
});

The output mounts itself on DOMContentLoaded, reads its own configuration off a root element's data-* attributes, and renders into a container it creates. No exports, no imports, nothing the host page has to know how to wire up. It behaves like a well-mannered analytics snippet, not a module in someone else's app.

The tradeoff is size discipline. Every dependency you bundle ships to every storefront visitor, on every page load, competing with the merchant's own theme JS for the critical rendering path. We set a hard budget of under 150KB gzipped for the whole widget and landed at roughly 65KB gzipped for the JavaScript and about 2.4KB gzipped for the CSS — React, the quiz logic, and the results UI included. That number is the thing to defend in code review, not a nice-to-have.

§ 02CSS that can't collide with a theme you've never seen

The second assumption you have to drop is that your CSS is the only CSS on the page. It isn't. It's loading into a stylesheet cascade shaped by whatever theme the merchant is running, plus whatever other apps that merchant has installed, plus whatever CSS resets or utility frameworks the theme author used. A widget built with an unscoped utility framework will, sooner or later, clobber a merchant's button styles or get its own layout silently overridden by a theme selector with higher specificity.

Our rule was: no shared class names, full stop. Every class in the stylesheet is prefixed — .nk-panel, .nk-trigger, .nk-step — and there's no reset, no Tailwind, nothing that reaches outside the widget's own root element. Theming is handled entirely through CSS custom properties scoped to that root, set from the merchant's configured brand color rather than hardcoded values:

/* All theming flows through custom properties on the root — nothing global */
.nk-widget {
  --nk-primary: #2563eb;
  --nk-primary-hover: color-mix(in srgb, var(--nk-primary) 85%, black);
  --nk-radius: 8px;
  --nk-shadow: 0 4px 24px rgba(0,0,0,.12);
  font-family: system-ui, -apple-system, sans-serif;
}
.nk-trigger { background: var(--nk-primary); border-radius: var(--nk-radius-full); }
.nk-trigger:hover { background: var(--nk-primary-hover); }

A merchant picks a color in the theme editor's color picker, that value lands on the root element as an inline custom property, and every prefixed rule downstream inherits it — no per-component overrides, no specificity fights, no risk that a theme's own button { } rule leaks into the widget or that the widget's rules leak out. It reads as unglamorous CSS, and that's exactly the point: boring, prefixed, self-contained styling is what survives contact with 8,000 unrelated themes.

You're not writing CSS for a page you control. You're writing CSS that has to be invisible to every other stylesheet already on that page, on a theme you'll never open.

— build notes, storefront widget project

§ 03Delivery: an app embed block, not a pasted script tag

The oldest way to get a widget onto a Shopify storefront was ScriptTag — an API that injected a script into every page of a store, with no merchant-facing control over where or whether it loaded. Shopify has deprecated it for new apps in favor of Theme App Extensions, and specifically the app embed block variant: a unit your app ships that a merchant turns on and off from the theme editor's "App embeds" panel, with zero Liquid editing and zero risk to their theme files. The block is a small Liquid template plus a settings schema. Shopify renders the schema as the actual configuration UI the merchant sees:

{% if block.settings.enabled %}
  <div id="widget-root"
       data-api-base="{{ shop.url }}/apps/quiz/api"
       data-shop-domain="{{ shop.permanent_domain }}"
       data-primary-color="{{ block.settings.primary_color }}">
  </div>
  {{ 'quiz-widget.css' | asset_url | stylesheet_tag }}
  {{ 'quiz-widget.js' | asset_url | script_tag }}
{% endif %}

{% schema %}
{ "name": "Quiz widget", "target": "body",
  "settings": [
    { "type": "checkbox", "id": "enabled", "default": true },
    { "type": "color", "id": "primary_color", "default": "#2563eb" }
  ] }
{% endschema %}

This is the difference that matters most for "works on any theme": a target: "body" app embed block loads the same way whether the merchant is on a legacy theme or a fully Online Store 2.0 theme, without them touching a template file, and Shopify signs off on it in App Store review specifically because the deprecated script-tag path doesn't give a merchant that kind of control. The settings schema also does double duty as the widget's configuration surface — color, button copy, position — so there's no separate admin screen a merchant has to find and no code change needed to rebrand the widget for a new store.

§ 04One codebase, isolated per merchant

The widget is single-tenant from a shopper's point of view but the backend behind it is very much multi-tenant — one API, one data platform, hundreds of independent merchant catalogs. The widget passes its shop_domain on every request, and every query on the backend is scoped by shop_id before anything else happens. A recommendation for merchant A can never surface merchant B's inventory, even though both are running the identical widget bundle against the identical API.

The part that's specific to this kind of product is connecting a merchant's own catalog to a shared underlying dataset without asking them to do manual data entry. We built a two-tier matching pipeline that runs when a merchant installs the app and again on every product webhook: first pass matches on barcode where one exists — an exact, high-confidence join — and a second pass runs fuzzy trigram similarity (Postgres's pg_trgm) against product name and brand for everything the barcode pass missed, auto-confirming above a high similarity threshold and leaving the rest for the merchant to review in a simple matching UI. The OAuth scope for all of this is a single read-only one — read_products — because the widget only ever needs to know what a merchant sells, never to change it.

This is the same discipline behind a full custom Shopify app: the widget you see is the thin edge of a backend that has to treat every request as coming from an untrusted, arbitrary tenant, because that's exactly what it is.

§ 05Checkout through the merchant's own cart, not yours

The quiz ends with an add-to-cart button, and that button cannot point at infrastructure you own — there's no such thing as "your" cart on someone else's store. It has to add the item to the merchant's cart, using their checkout, with their tax, currency, and discount logic already applied. Shopify's storefronts expose this as the AJAX Cart API, a small set of theme-scoped endpoints available on any store regardless of theme, and /cart/add.js is the one that matters here: a same-origin POST with a variant ID and quantity that returns the updated cart.

// Add to the merchant's own cart — same-origin, no widget backend involved
async function addToCart(variantId) {
  const res = await fetch("/cart/add.js", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ items: [{ id: variantId, quantity: 1 }] }),
  });
  if (res.ok) {
    document.dispatchEvent(new CustomEvent("cart:refresh"));
  }
}

Two things fall out of using the store's own endpoint instead of a custom checkout flow. First, the widget never needs to know about payments, shipping rules, or tax jurisdictions — that's the merchant's checkout, untouched, doing what it already does. Second, firing a cart:refresh custom event after a successful add lets most themes' existing cart-drawer or cart-count UI pick up the change on its own, because that convention is common enough across themes that the widget doesn't have to reach into the DOM and manually update a cart icon it doesn't own. When the request fails — a theme that's disabled AJAX cart, a variant that's sold out — the widget degrades quietly rather than throwing an error into a storefront it doesn't control.

The widget's job ends at "add this variant." Everything after that — tax, currency, discounts, the actual purchase — belongs to a checkout you will never touch and shouldn't want to.
— why we don't build custom checkouts for widget projects

§ 06What the constraint list actually buys you

None of this is exotic engineering. IIFE bundling, prefixed CSS, an app embed block, tenant-scoped queries, and a same-origin fetch to a documented endpoint — every one of these is a known pattern. The value is in treating all five as non-negotiable from day one rather than discovering them one merchant support ticket at a time. A widget that assumes it's the only script on the page, or that its CSS is safe to leave unscoped, works fine in a demo and breaks on the third theme a real merchant installs it on.

We shipped this particular widget as the storefront layer of a larger pet-nutrition data platform — the widget is a thin, portable UI in front of a much larger recommendation engine and product database, which is the shape most embeddable-widget projects end up taking once there's real data behind them. If you're scoping something similar — a quiz, a configurator, a recommendation panel — that has to survive on storefronts you'll never see the theme code for, the constraints above are the ones worth designing around before the UI, not after.


— End of essay. Need a widget that survives every theme your merchants throw at it? Start a project →

dir

h4

meta
dir

h4

meta

Need a widget that survives every theme?

IIFE bundling, scoped CSS, app embed blocks, tenant isolation, native cart integration — we build storefront widgets that hold up on stores we've never seen. Let's talk about yours.

Book an intro call