@natanvotre/builder-shopify-plugin
v0.1.1
Published
Multi-store Shopify plugin for Builder.io — search products and collections across regional storefronts (US/UK/EU) in a single picker
Downloads
207
Readme
@natanvotre/builder-shopify-plugin
A Builder.io commerce plugin that connects multiple Shopify storefronts as a single resource source. Search products and collections across regional storefronts (e.g. US/UK/EU) in one picker, with availability pills showing which stores carry each item.
Status: Internal FormFactory reference implementation. Currently configured as the production plugin for e.l.f. Cosmetics (US/CA/IN, UK, EU). Fork and adapt for new clients — see Adapting for a new client.
Why this exists
Builder.io ships a first-party Shopify Commerce Plugin. It works well — for a single store.
Brands with regional Shopify storefronts hit a wall:
| Problem with the default plugin | Impact | | ---------------------------------------------- | ------------------------------------------------------------------------ | | One Shopify domain + token per Builder space | Editors can only reference resources from one store | | Resource IDs are bound to a specific store | A "Product" model in Builder can't represent the same SKU across regions | | No visibility into where a SKU actually exists | Editors guess; broken links ship to regions where the product is missing |
For a multi-region brand, this either forces one Builder space per region (triple the content management overhead) or per-region Builder content models (every page template duplicated by region). Neither scales.
What this plugin does instead
- Connects to all configured stores at once (US, UK, EU) via three Storefront API tokens
- Searches in parallel across stores and merges by handle — the same product across regions appears as a single result with store pills (🇺🇸 🇬🇧 🇪🇺) showing availability
- Stores the product
handleas the canonical reference — the storefront resolves the right Shopify store at render time based on the request locale
This means one Builder content model, one editor experience, and clean multi-region rendering downstream.
How it works
┌─────────────────────────────┐
│ Builder.io Editor │
│ (Resource Picker UI) │
└────────────┬────────────────┘
│ search("brow power")
▼
┌─────────────────────────────┐
│ ElfResourcePicker │
│ (debounced search) │
└────────────┬────────────────┘
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ US Client│ │ UK Client│ │ EU Client│
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Shopify │ │ Shopify │ │ Shopify │
│ US/CA │ │ UK │ │ EU │
└──────────┘ └──────────┘ └──────────┘
│
▼
┌─────────────────────────────┐
│ mergeByHandle() │
│ one row per handle, │
│ stores: ['US','UK','EU'] │
└────────────┬────────────────┘
▼
┌─────────────────────────────┐
│ Picker rows w/ store pills │
└─────────────────────────────┘Key concepts
- Handle as canonical ID — Shopify products with the same
handleacross stores are treated as the same product. The plugin storeshandle(not the Shopify GID) as the resource ID Builder persists. - Promise.allSettled across stores — one store's failure doesn't break the picker. If the UK API is down, US and EU results still load.
- Store pills — small flag chips next to each result so editors can see at a glance which regions carry a product before they pick it.
File map
| File | Purpose |
| --------------------------- | ------------------------------------------------------------------ |
| src/plugin.ts | Builder plugin registration + the 6 settings (3 tokens, 3 domains) |
| src/data-plugin.ts | Shopify clients, search, findByHandleAcrossStores, merge logic |
| src/ElfResourcePicker.tsx | The picker UI (debounced search, list rows, store pills) |
| src/StorePill.tsx | The flag chip component (US/UK/EU SVG flags) |
| src/stores.ts | Single source of truth for the supported StoreKey union |
Setup (using the plugin in Builder.io)
1. Get Shopify Storefront API tokens
For each store, in the Shopify admin:
- Apps → Develop apps → Create an app → Configure Storefront API integration
- Grant scopes:
unauthenticated_read_product_listings,unauthenticated_read_product_inventory,unauthenticated_read_collection_listings - Copy the Storefront access token
2. Host the built plugin
Builder.io loads plugins from a URL. Build the bundle and host it somewhere Builder can fetch (HTTPS, public).
pnpm install
pnpm build
# → dist/plugin.system.jsHosting options:
- GitHub Pages off this repo's
dist/(simplest for internal use) - Your own CDN (S3 + CloudFront, Vercel static, etc.)
unpkg.comif published to npm (https://unpkg.com/@natanvotre/builder-shopify-plugin@<version>/dist/plugin.system.js)
3. Register the plugin in Builder.io
In your Builder org: Account → Organization → Plugins → Add
plugin → paste the URL of plugin.system.js. Reload Builder.
4. Configure the 6 settings
Builder will prompt for the plugin settings. Provide:
| Setting | Example |
| ------------------------- | ------------------------------- |
| storefrontAccessTokenUS | shpat_… |
| domainUS | elfcosmetics.myshopify.com |
| storefrontAccessTokenUK | shpat_… |
| domainUK | elfcosmetics-uk.myshopify.com |
| storefrontAccessTokenEU | shpat_… |
| domainEU | elfcosmetics-eu.myshopify.com |
Once saved, any Builder field of type ProductPreview or CollectionPreview
will use this plugin's picker.
Development
Prerequisites
- Node 20+
- pnpm 9+
Install + build
pnpm install
pnpm build # bundles to dist/plugin.system.js
pnpm types:check # tsc --noEmit
pnpm lint:check # oxlint + eslint
pnpm format:check # prettierStorybook
The picker UI components have isolated stories. Useful when iterating on the store pills or list rows without touching the Builder.io editor.
pnpm storybook:dev # http://localhost:6007Local plugin development against Builder.io
Builder loads plugins from a URL, so for local dev you need to either:
- Tunnel — run
pnpm build && npx http-server dist -p 1268 --corsthenngrok http 1268and register the ngrok URL in Builder - Build → push → reload loop — push to your hosted location and refresh Builder
Tunnel is the fast inner loop. Push-and-reload is fine for finishing touches.
Adding a new store
- Add the new key to
src/stores.ts:export const STORES = { US: {id: 'US' as const, label: 'US'}, UK: {id: 'UK' as const, label: 'UK'}, EU: {id: 'EU' as const, label: 'EU'}, CA: {id: 'CA' as const, label: 'CA'}, // ← new } as const - Add a flag SVG in
src/StorePill.tsxunderFLAG_SVGS - Add the two settings (token + domain) in
src/plugin.ts - Add a
make()line inbuildClients()insrc/data-plugin.ts - Add the new client to the
Promise.all([...])calls insearch() - Rebuild + re-register the plugin in Builder
TypeScript will guide you — StoreKey is the source of truth and most call
sites are typed against it.
Adapting for a new client
This repo is currently configured as e.l.f.'s plugin instance. To fork it for a new multi-store client:
- Repo: fork or template-copy this repo to a new internal repo
- Plugin identity in
src/plugin.ts:id— change to@theformfactory/<client>-builder-shopify-plugin(must be unique within Builder)name— display name in Builder admin (e.g.ClientShopify)ctaText— wording shown in the "Connect…" button
- Settings: rename
helperTextexamples to the client's domains - Stores: edit
src/stores.tsto match the client's regions - Picker class name:
ElfResourcePicker→<Client>ResourcePicker(cosmetic) - Package name: update
nameinpackage.json
Most of the merge/search logic is store-shape-agnostic and doesn't need touching.
Architecture notes / trade-offs
- Why handle and not GID as the canonical ID? GIDs are per-store; handles
are typically the same across regions. Using
handleas the ID makes cross-region content portable in Builder. - What if handles diverge across regions? They show up as separate rows. This is the right default — diverged handles usually mean "different product, similar name" and shouldn't be conflated.
- Why
Promise.allSettledand notPromise.all? A single store's outage shouldn't grey out the whole picker. Settled-with-errors falls back to a partial list. - Why bundle as SystemJS? Builder's plugin host is SystemJS-based — this is
what
registerCommercePluginexpects. @material-ui/corev4 peer dep? Builder's plugin runtime ships MUI v4 globally. Plugins must use the host's copy (hence the peer dep + Rollup external).
License
Internal — FormFactory and its clients only. Not for redistribution.
