src/widgets/, and the SDK renders it at that zone while the rest of the page — its data fetching, filters, pagination — stays exactly as shipped. This is the lightest way to add UI to a page you don’t own.
Additive, not a replacement. Unlike a drop-in route (which owns the whole page), a widget layers your component onto the built-in page at a documented zone. Reach for this first when you just want to add something to an existing screen.
What you’ll build
A tip banner above the vendor product list, rendered from a single file — with the list itself untouched.Register the typed targets (once)
Widget zones are typed ids that the vendor panel generates from its own pages and ships as@mercurjs/vendor/extension-targets. Register them once so the ids resolve everywhere, with a single ambient reference in your app’s src:
apps/vendor/src/extension-targets.d.ts
create-mercur-app already ship this file. With it present, an invalid zone fails tsc instead of silently doing nothing.
Add the widget
Create the widget file
Drop a file under
src/widgets/. Export the component as the default and a config built with defineWidgetConfig. The zone names where it renders:apps/vendor/src/widgets/product-list-banner.tsx
Understand the zone id
A zone id is
Multiple
<domain>.<view>.<placement>. The last segment is the placement:| Placement | Effect |
|---|---|
before | Renders before the built-in content of the zone |
after | Renders after the built-in content |
before / after widgets on the same zone stack in registration order.Available zones
Zones mounted today in the vendor portal:| Zone | Where it renders |
|---|---|
product.list.before / .after | Vendor product list page |
store.setup.before / .after | The store-setup / onboarding surface (dashboard home + store settings), passed the seller as data |
login.logo.* | The logo slot on the public login screen |
login.before.* / login.after.* | Around the login form (rendered before authentication) |
WidgetZoneId and generated into @mercurjs/vendor/extension-targets from the panel’s own zone hosts — let your editor autocomplete zone: to see every option. A zone no page renders can’t be targeted and won’t type-check.
Verify
- The tip banner renders above the product list.
- Search, filter, and paginate the list — all built-in behavior still works.
- Change the zone to
product.list.afterand reload — the banner moves below the list. - Set
zone: "not.a.zone"—tsc(bun run lint) fails with a “not assignable toWidgetZoneId” error. - Delete the file — the banner disappears; nothing else changed.
FAQ
Can a widget target more than one zone?
Can a widget target more than one zone?
Yes —
zone accepts an array (zone: ["product.list.before", "login.after.before"]), and the same component renders at each.Can a block ship widgets?
Can a block ship widgets?
Yes. A block can include
src/widgets/ files in its vendor_ui / admin_ui entry, and they’re aggregated just like the host app’s — installing the block adds the widget with no wiring.Does the admin panel have widget zones too?
Does the admin panel have widget zones too?
The zone set is per panel and generated from each panel’s pages. Today the mounted zones live in the vendor portal (
product.list.*, login.*); the admin panel exposes navigation and product custom fields. Check @mercurjs/admin/extension-targets for its current zones.Next steps
Extend forms and tables
Add validated fields and columns with defineCustomFieldsConfig.
Extend the onboarding flow
Add a store-setup field and persist it through a workflow hook.