How the Shopify theme app extension personalizes merch on a merchant storefront, and how to run it locally
Zooly’s Shopify app lets a merchant sell personalized merch on their own Shopify store. A shopper personalizes on the product page (selfie → AI art → mockup), adds to the Shopify cart, checks out through Shopify, and Zooly fulfills the order through the existing supplier pipeline.
Tracked on ZLY-1526. The Shopify Partner app shell lives in apps/zooly-shopify/; almost all product logic lives in packages and is served by apps/zooly-app.
These were locked early and still drive the shape of the code:
| Decision | Why |
|---|---|
| No iframe | Theme app extensions that load an iframe fail App Store review. The UI ships as a JS/CSS bundle in the theme extension assets/. |
| Shadow DOM deferred | Planned for theme isolation; currently the block mounts in the light DOM. Theme CSS can still interfere — acceptable until we harden for listed apps. |
Backend is zooly-app | It already owns the merch DB, merch APIs, and fulfillment dispatch. Cross-app DB access is not allowed, so there is no separate Shopify server. |
| App proxy for storefront APIs | The bundle calls /apps/zooly/* on the merchant’s domain. Shopify proxies to us with an HMAC signature → same-origin (no CORS), and we know which shop called. |
| Zooly fulfills | orders/create creates an External Order in our system and feeds the existing Printful / Riverr / Apliiq drivers. Shopify only collects payment and shipping. |
| We auto-create Shopify products | Merchants enable product × design combinations in the embedded admin. We create variants via Admin GraphQL productSet so sizes always match what we can fulfill. |
| Sellable unit = product × design | “T-Shirt — Design 1” and “T-Shirt — Design 2” are separate Shopify products with different rendered previews. shopify_product_map stores design_id. |
| Driver seam for the block | MerchBlockDriver (upload / verify / generate / select / render). Mock and proxy implementations share one UI; the theme editor can still select the mock for demos. |
| Managed install + token exchange | No hand-rolled OAuth callback. Scopes are granted at install; the embedded admin exchanges an App Bridge session token for an offline access token. |
| Merchant-managed fulfillment for tracking | We do not register as a full Shopify fulfillment service. Tracking is pushed with fulfillmentCreate under write_merchant_managed_fulfillment_orders. |
External Order = merch_order + satellite | Shopify orders become normal merch_order / merch_item rows (source = 'shopify') plus a shopify_order satellite. Dispatch, admin, and crons stay unchanged. |
orders/create and only need to push tracking on merchant-managed fulfillment orders.Merchant storefront
App block (Liquid) → loader JS (≤10 KB) → React IIFE bundle
│ same-origin
▼
/apps/zooly/* (Shopify app proxy, HMAC-signed)
▼
apps/zooly-app /api/shopify/proxy/*
│ allowlisted forward
▼
/api/merch/* · /api/media/* · /api/shopify/storefront/*
Add to cart → Shopify checkout → orders/create webhook
▼
createMerchOrderFromShopifyOrder → finalizeMerchOrder → supplier dispatch
When status → shipped → pushTrackingForMerchOrder → Shopify FULFILLED
| Piece | Location |
|---|---|
| Partner app + theme / UI extensions | apps/zooly-shopify/ |
| Theme block Liquid + loaders | apps/zooly-shopify/extensions/zooly-merch/ |
| Thank-you UI extension (parked) | apps/zooly-shopify/extensions/zooly-order-preview/ |
| Block React UI + drivers | packages/merch/client/src/shopify/ |
| Shopify HMAC, Admin GraphQL, storefront context, product sync, tracking | packages/shopify/srv/ |
| Embedded merchant admin (App Bridge) | packages/shopify/client/ |
| Thin API routes | apps/zooly-app/app/api/shopify/ |
| Order intake + finalize | packages/merch-fulfillment/srv/ (shopify-order-intake.ts, finalize-order.ts) |
| DB tables + access | packages/db — shopify_shop, shopify_product_map, shopify_order; merch_order.source |
@zooly/merch-client emits an IIFE + CSS (zooly-merch-app.js / .css) from packages/merch/client/src/shopify/.assets/ (gitignored build artifacts).zooly-merch.js) in the block schema — theme check hard-caps that file at 10 KB. The loader lazy-loads the real bundle via IntersectionObserver and asset_url.npm run bundle --workspace zooly-shopify # once
npm run bundle:watch --workspace zooly-shopify # alongside app dev
Shopify CLI watches assets/ and re-uploads on change; it does not know how to produce the React bundle, so bundle:watch must run in parallel with app dev.
The proxy verifies HMAC, then only forwards paths under:
merch/ (excluding admin / cron / seller / sync / webhooks)media/ (same-origin generated art)shopify/storefront/ (public product→campaign mapping)Without the allowlist, a storefront could reach privileged merch admin routes on our origin.
Line item properties on add-to-cart (underscore = hidden from the shopper UI):
| Property | Role |
|---|---|
_zooly_design_id | Design identity for intake |
_zooly_catalog_product_id | Catalog product |
_zooly_session | Merch session |
_zooly_art_key | Selected art for print |
_zooly_preview | Absolute mockup URL for cart thumbnail swap |
Preview (optional public) | Human-visible label where themes show properties |
The Zooly cart previews app embed (zooly-cart-preview) reads /cart.js and swaps thumbnails. It is off by default (Shopify rule for app embeds). Native Add to cart / Buy it now are hidden by Liquid :has() when the personalizer is present on a Zooly product; fail-open via data-zooly-unavailable if storefront context says the product is not ours.
syncShopifyProduct writes metafield zooly.personalizable. Liquid gates the block on that value so non-Zooly products on the same template do not flicker buy buttons. In the theme editor only, a non-personalizable product shows an explanation instead of an empty slot.
shopify:{shopId}:{orderId}.mode: PREVIEW so they never dispatch to a real supplier.needsAttention instead of a null print file.(shop_id, shopify_order_id) on shopify_order.customers/redact, shop/redact) are still acknowledge-only — fine for private/unlisted installs; must delete External Order PII before App Store submit.npm run db:migrate with .env.local — never migrate remote unless asked).zooly if media / generation paths need it.shopify.app.toml is gitignored (each developer links their own app). Run interactively, one command at a time — do not paste the next command while the CLI is prompting:
cd apps/zooly-shopify
npx @shopify/cli@latest app config link
Always pass --skip-dependencies-installation to Shopify CLI commands in this monorepo. Without it the CLI finds bun.lock and runs bun install across the repo.
.env.local)npm run env:show --workspace zooly-shopify
Copy into the root .env.local (and restart zooly-app after changes):
SHOPIFY_API_KEY=... # client id
SHOPIFY_API_SECRET=... # server-only; verifies HMAC + session tokens
SHOPIFY_PROXY_TARGET_ORIGIN=http://localhost:3004 # local only
SHOPIFY_PROXY_TARGET_ORIGIN prevents the proxy from forwarding back through the Cloudflare tunnel (which deadlocks and 502s). Leave it unset in production.
Optional: SHOPIFY_API_VERSION (defaults to 2026-04).
Order matters: tunnel before app dev. Deploying config while app dev is live can crash the preview and leave a stale proxy.
# 1 — backend
npm run dev --workspace zooly-app # :3004
# 2 — Cloudflare tunnel + rewrite shopify.app.toml URLs + deploy config
npm run tunnel --workspace zooly-shopify
# 3 — React bundle for the theme extension
npm run bundle:watch --workspace zooly-shopify
# 4 — theme extension / embedded app preview
npx shopify app dev --skip-dependencies-installation
# (from apps/zooly-shopify, or: npm run dev --workspace zooly-shopify)
automatically_update_urls_on_dev is false on purpose so the CLI’s own tunnel does not overwrite application_url (which must point at zooly-app via cloudflared).
Pass --no-deploy to tunnel if you only want to rewrite the toml without releasing a version.
Open the embedded app from the Shopify admin. The first open exchanges the session token and stores shopify_shop.accessToken. Until then, Admin API calls fail with a clear error rather than a raw Shopify 401.
Link a Zooly campaign and enable product × design combinations so maps and metafields exist.
Signs requests the way Shopify does:
npm run probe --workspace zooly-shopify merch/campaigns # expect 200
npm run probe --workspace zooly-shopify merch/admin/orders # expect 404 (allowlist)
/cart/add.js)._zooly_* properties.PREVIEW (no real supplier dispatch).| Check | Passes when |
|---|---|
| Non-Zooly product, same template | Personalizer absent; buy buttons stay |
| Theme editor on non-Zooly product | Explanation text, not a blank block |
| Unmapped size selected | Add refused (no orphan personalization) |
| Storefront context 404/409 | data-zooly-unavailable; buy buttons return (fail-open) |
| Two personalized adds | Two separate cart lines (unique design/session props) |
In the block settings, set Data source to mock. Upload still looks real (canvas-filter candidates); nothing hits the proxy. Useful for demos without generation cost.
| Symptom | Likely fix |
|---|---|
No shopify.app.toml | Finish app config link |
bun install errors from CLI | Add --skip-dependencies-installation |
/apps/zooly/* 404 on storefront | Restart app dev after tunnel/deploy so the preview has the proxy |
| Tunnel 502 on merch calls | Set SHOPIFY_PROXY_TARGET_ORIGIN=http://localhost:3004 and restart zooly-app |
| New Admin API capability silently missing | Uninstall + reinstall the app (scopes stick to the old token) |
onlineStoreUrl null | Password storefront — check resourcePublicationsV2, not the URL |
| Block missing from theme Apps | Theme section does not accept app blocks; try Dawn |
| Storefront password prompt | Dev store password under Online Store → Preferences |
npm run deploy --workspace zooly-shopify
That creates an app version other stores (and eventually App Store review) can install. Local app dev only affects your development preview.
On This Page
What this isDesign decisionsThings we deliberately did not doArchitecturePackages and appsHow the storefront bundle buildsApp proxy allowlistCart and preview propertiesProduct gatingOrder intake quirks worth knowingLocal setupPrerequisitesOne-time: link the Partner appEnvironment (root ,[object Object],)Terminals to run every sessionMerchant admin (first open)Theme editorHow to test locallyProxy without a storefrontStorefront happy pathVariant / gating checksMock driver (offline UI)Known gaps while testingTroubleshootingDeploying beyond your dev store