Custom Theme Widgets¶
A theme can ship its own builder widgets — new draggable blocks that appear in the merchant dashboard's "Add a section" gallery (under a Theme category) and render on the storefront. Merchants can use them on custom pages/landing pages AND as sections on default pages (product, cart, checkout, …).
A custom widget has two halves:
| Half | File | Consumed by |
|---|---|---|
| Definition | widgets/x-<slug>-<name>/widget.json |
Merchant dashboard — gallery card + settings panel |
| Component | widgets/x-<slug>-<name>/index.tsx |
Storefront — the actual render |
1. Naming: the x-<slug>- namespace¶
Every custom widget name MUST be x-<themeSlug>-<widget> (lowercase,
hyphens), e.g. theme aurora → x-aurora-hero, x-aurora-countdown. The
folder name, the name field in widget.json, and the component's map key
must all be identical. The prefix guarantees your widgets can never collide
with the platform's canonical widgets or another theme's.
Names are globally unique across the marketplace — registration fails if another theme already claimed the name (the prefix makes this effectively impossible unless slugs collide).
2. Scaffold one¶
npm run scaffold:widget x-<yourslug>-hero
creates:
src/themes/custom/widgets/
├── index.ts # name → component map (auto-updated)
└── x-<yourslug>-hero/
├── widget.json # definition
└── index.tsx # render component
3. The definition — widget.json¶
Full JSON Schema: schemas/widget-def.schema.json in the kit. Example:
{
"name": "x-aurora-hero",
"label": "Aurora Hero",
"icon": "Sparkles", // dashboard icon key; unknown → Sparkles
"description": "Full-width hero with headline and CTA",
"version": "1.0.0",
"defaults": { // initial widget.data for a new instance
"settings": {
"title": "Welcome",
"subtitle": "",
"ctaLabel": "Shop now",
"ctaLink": "/",
"bgColor": "#111827"
}
},
"fields": [ // settings-panel controls
{ "path": "settings.title", "label": "Title", "type": "text", "group": "content" },
{ "path": "settings.subtitle", "label": "Subtitle", "type": "textarea", "group": "content" },
{ "path": "settings.ctaLabel", "label": "Button text", "type": "text", "group": "content" },
{ "path": "settings.ctaLink", "label": "Button link", "type": "link", "group": "content" },
{ "path": "settings.bgColor", "label": "Background", "type": "color", "group": "advanced" }
]
}
Key rules:
fields[].typemust be one of the dashboard's native controls:text,textarea,number,range,switch,select,color,link,richtext,media(image picker),bg-image,image-list,repeater,tabs,product-picker,category-picker,brand-picker,logical-builder. The settings panel renders them for free — no dashboard code needed on your side.repeateredits an array of objects. Declare the per-item controls initemFields(same shape asfields, but paths are relative to the item):
jsonc
{ "path": "settings.tiles", "label": "Tiles", "type": "repeater",
"addLabel": "Add tile",
"itemFields": [
{ "path": "title", "label": "Title", "type": "text" },
{ "path": "image", "label": "Image", "type": "media" },
{ "path": "href", "label": "Link", "type": "link" }
] }
pathis a dot path intowidget.data(convention: presentation underdata.settings).defaultsis deep-cloned for each new instance the merchant adds.groupplaces the control on a settings tab:content,layout, oradvanced. Standard style controls (width, padding, background, per-device visibility) are appended to every widget automatically.
Defaults must be complete — and presentable¶
defaults is not just the initial value for new instances: the dashboard also
back-fills it into instances whose stored data is empty (e.g. seeded from
your templates), so what you put here is exactly what the merchant sees and
edits. Two hard rules, both learned the painful way:
- Ship a real value for every field you declare — especially images.
A widget whose default
imageis""renders as an empty tinted box on the merchant's brand-new storefront and an empty settings panel in the builder. Use royalty-free absolute URLs (e.g. Unsplash) as placeholder imagery; they render as-is and the merchant replaces them via the media picker. - Your component's per-field fallbacks must MATCH
defaults. The storefront falls back per-field (s.title || "…"); if the fallback text or image differs fromwidget.json, the builder shows one thing and the storefront another — the merchant edits blind.
4. The component — index.tsx¶
The storefront passes the builder widget object; read your settings from
widget.data.settings (the shape you declared in defaults):
"use client";
interface WidgetProps {
widget: { id: string; name: string; data: { settings?: Record<string, any> } };
}
export default function AuroraHero({ widget }: WidgetProps) {
const s = widget?.data?.settings || {};
return (
<section className="w-full py-16" style={{ backgroundColor: s.bgColor }}>
<div className="mx-auto max-w-7xl px-4 text-center text-white">
<h1 className="text-4xl font-bold">{s.title}</h1>
{s.subtitle && <p className="mt-3 text-lg opacity-80">{s.subtitle}</p>}
{s.ctaLabel && (
<a href={s.ctaLink || "/"} className="mt-6 inline-block rounded bg-white px-6 py-3 text-gray-900">
{s.ctaLabel}
</a>
)}
</div>
</section>
);
}
Component constraints (enforced by validation + review):
- Imports limited to React, the theme import barrel, npm UI packages already
in the platform, and your own theme files. No Node built-ins, no own
network calls, no
eval/new Function/dangerouslySetInnerHTML. - Render defensively — every setting can be empty/missing.
- Your component is wrapped in an error boundary on the storefront, but a crash still means your widget renders nothing; don't throw.
Media fields hold S3 keys — always resolve them¶
The dashboard's media/bg-image/image-list pickers store
shop-relative S3 keys (my-shop/media/169…webp), not URLs. Rendering the
raw value 404s the image on the storefront. Resolve every image/video setting
with getS3ImageUrl(value, "MEDIA") from the theme import barrel; it passes
absolute URLs (your placeholder defaults) through untouched. The clean pattern
is one helper in your <theme>-shared.tsx, used by every widget:
// <theme>-shared.tsx
import { getS3ImageUrl } from "../imports";
export const themeMedia = (src?: string): string =>
src ? getS3ImageUrl(src, "MEDIA") : ""; // "" keeps your gradient/placeholder fallback
// in a widget
style={{ backgroundImage: t.image ? `url('${themeMedia(t.image)}')` : FALLBACK_GRADIENT }}
This bug is invisible in the kit preview (mock values are absolute URLs) and
in tsc — it only appears after a merchant picks their own image. Grep your
widgets for unwrapped image/poster/videoUrl usages before packaging.
Register it in widgets/index.ts (scaffold does this automatically):
import AuroraHero from "./x-aurora-hero";
export const themeWidgets: Record<string, any> = {
"x-aurora-hero": AuroraHero,
};
export default themeWidgets;
5. How registration works (nothing extra to do)¶
When your theme is approved:
- Definitions from
widgets/*/widget.jsonare registered in the platform (theme_widget_defs). The merchant dashboard fetches the ACTIVE theme's defs and merges them into its builder gallery under the Theme category. - Components are compiled into the storefront build by the integration CI, keyed by widget name per theme.
- At render time the storefront resolves a widget name in this order: default-page sections → canonical widgets → the active theme's custom widget map → nothing.
You can also use your own custom widgets inside your theme's
templates/{type}.json default-page layouts — they validate like any section
name. Give each template instance its full data (a copy of the widget's
defaults, customized for the page) rather than an empty
{ "settings": {} }. The platform hydrates empty instances from your
defaults at seed time as a safety net, but explicit data is what makes the
merchant's builder panel, the preview, and the storefront all show the same
thing from day one.
6. What happens when the merchant switches themes¶
Custom widgets are scoped to the active theme:
- Pages keep the widget data (never stripped).
- The storefront renders nothing where the inactive theme's widgets were.
- The dashboard shows a "(theme inactive)" badge on those blocks; the merchant can delete them or reactivate your theme to bring them back.
- The pre-apply design snapshot remains the full-recovery path.
Design your widgets so a page still reads sensibly without them — they should enhance a layout, not carry critical information alone.
7. Test locally, then ship¶
npm run validate # def schema, namespace, index.ts exports, banned code
npm run package # → dist/theme.zip including widgets/
Then submit the zip as usual — see Theme Development Guide § Submission.
Updating a widget¶
- Additive setting changes are safe: new
fields+ newdefaultskeys apply to new instances; existing instances keep their saved data. - Never repurpose an existing
pathto mean something different — merchant pages already store values under it. - Removing a widget from the package unregisters it: existing instances behave like inactive-theme widgets (hidden + badge).