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 aurorax-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[].type must 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.
  • repeater edits an array of objects. Declare the per-item controls in itemFields (same shape as fields, 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" } ] }

  • path is a dot path into widget.data (convention: presentation under data.settings).
  • defaults is deep-cloned for each new instance the merchant adds.
  • group places the control on a settings tab: content, layout, or advanced. 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:

  1. Ship a real value for every field you declare — especially images. A widget whose default image is "" 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.
  2. Your component's per-field fallbacks must MATCH defaults. The storefront falls back per-field (s.title || "…"); if the fallback text or image differs from widget.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:

  1. Definitions from widgets/*/widget.json are 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.
  2. Components are compiled into the storefront build by the integration CI, keyed by widget name per theme.
  3. 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 + new defaults keys apply to new instances; existing instances keep their saved data.
  • Never repurpose an existing path to 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).