@mercurjs/dashboard-sdk). Customization is file-based and convention-driven — you add pages, widgets, and field extensions by dropping files under src/, and configure navigation through a single file. Nothing is registered by hand; the SDK crawls your src/ at build time and wires everything in.
Coming from Medusa? The extension model is deliberately Medusa-shaped. The helpers you know —
defineWidgetConfig, defineRouteConfig, defineCustomFieldsConfig, createFormHelper — all exist here, re-exported from @mercurjs/dashboard-sdk (without Medusa’s unstable_ prefix). What’s different: Mercur ships two panels (admin and vendor) from one framework, so a file only ever targets the panel it lives in (there is no surface field), and widget zones carry a before | after placement suffix on the zone id. Everything is additive by default — your contribution augments the built-in page instead of replacing it.The extension mechanisms
Every panel customization is additive — you augment a built-in page without owning it. One concern per file, discovered by the build-time crawl:- Widgets — inject a React component at a named zone on a built-in page (
defineWidgetConfig). - Navigation — reorder, hide, relabel, or re-parent built-in sidebar items (
defineNavigationConfig). - Custom fields — add validated fields to built-in forms, replace/remove/add fields in detail sections, and add columns to list tables (
defineCustomFieldsConfig+createFormHelper). - Pages — add a brand-new screen with a drop-in
page.tsxroute.
Additive tools leave the rest of the built-in page — data fetching, filters, pagination, i18n — completely intact. A widget, a nav override, or a custom field changes only the spot you target.
Choosing your extension mechanism
Rule of thumb: injecting UI into an existing page? Use a widget. Adding data to a form or section? Use a custom field. Reshaping the sidebar? Use the navigation file. Adding a whole new screen? Drop in a route.| You want to… | Use | Why it’s the right tool |
|---|---|---|
| Show a banner, panel, or CTA on a built-in page | A widget (defineWidgetConfig) | Renders at a named zone; the rest of the page is untouched — see Add a widget |
| Add a field to a built-in form, or add/replace/remove a field in a detail section | A custom field (defineCustomFieldsConfig) | Validated field wired into the existing form/section — see Extend forms and tables |
| Add or override a column on a built-in list table | The list block of a custom field file | Model-scoped column extension — see Extend forms and tables |
| Reorder / hide / relabel / re-parent sidebar items | The navigation file (defineNavigationConfig) | One host-owned _navigation.ts — see Customize navigation |
| Add a new screen or feature | A drop-in page.tsx route | New URL, auto-registered, sidebar entry via config — see Add a page |
| Reuse a feature across projects | A block | Ships API + admin + vendor files installable with mercurjs add |
Set up
All configuration lives in the panel app’s Vite config — there is no separatemercur.config.ts.
Register the plugin
Add Projects created with
mercurDashboardPlugin to the panel app’s vite.config.ts. The only required option is medusaConfigPath — the plugin reads panel paths and ports from your API’s Medusa config:vite.config.ts
create-mercur-app ship with this already wired for both panels.Pass environment values explicitly
The plugin doesn’t read
.env itself — load environment variables in vite.config.ts (e.g. with Vite’s loadEnv) and pass them into the plugin options, as the starter template does with backendUrl.Register typed extension targets (once per panel)
Widgets, navigation, and custom fields target typed ids (zone ids, nav item ids, model field ids) that each panel package generates from its own built-in pages and ships as (For the admin app, reference
@mercurjs/{admin,vendor}/extension-targets. Register them once so the ids resolve in every extension file, with a single ambient reference in your app’s src:apps/vendor/src/extension-targets.d.ts
@mercurjs/admin/extension-targets.) With this file present, a typo in a zone, model, field, or nav id fails tsc instead of silently doing nothing at runtime. create-mercur-app ships this file already.Configuration options
| Option | Type | Description |
|---|---|---|
medusaConfigPath | string | Required. Path to your API’s medusa-config.ts, relative to the panel project root |
backendUrl | string | Medusa backend URL (default: http://localhost:9000) |
vendorUrl | string | Absolute vendor portal URL including its path prefix |
name | string | Application name shown in the sidebar |
logo | string | URL to a logo image |
i18n | object | Internationalization settings ({ defaultLanguage }) |
enableSellerRegistration | boolean | Enable the public seller registration flow (vendor portal) |
imageLimit | number | Max upload size for images in bytes (default: 2 MB) |
Widgets
Inject a React component at a named zone on a built-in page. Drop a file undersrc/widgets/, export the component as the default and a config built with defineWidgetConfig:
src/widgets/product-list-banner.tsx
<domain>.<view>.<placement>. The placement is the last segment:
before/after— stack your widget before or after the built-in content (multiple widgets stack in registration order).
| Zone id | Where it renders |
|---|---|
product.list.before / .after | Vendor product list page |
login.logo.* / login.before.* / login.after.* | The public login screen (rendered before authentication) |
WidgetZoneId and generated into each panel’s extension-targets.d.ts — your editor autocompletes them, and an unknown zone fails tsc. Walk through it end to end in Add a widget.
Navigation
Custom drop-in routes place their own sidebar item viadefineRouteConfig (see Add a page). To reshape the built-in sidebar items, author a single host-owned file, src/_navigation.ts:
src/_navigation.ts
idtargets any built-in item — top-level routes or nested children — by its stable id, typed asNavItemId.rankorders an item within its parent;hiddenremoves it from the sidebar;label/iconrelabel it;nestedre-parents it (nested: nullpromotes to top level, typed againstNavParentId).- Navigation is a single host-owned file — installed blocks cannot reorder the sidebar, so it stays one source of truth. It does not change custom routes, which still place themselves via
defineRouteConfig.
Custom fields
Add validated fields to a model’s built-in create/edit forms, replace/remove/add fields in its detail sections, and add columns to its list table — all from one model-scoped file. Dropsrc/custom-fields/<model>.tsx and default-export a defineCustomFieldsConfig:
src/custom-fields/product.tsx
forms[]adds validated fields to a formzone(create/edit/organize/ …). Input type and validation come from a Zod schema viacreateFormHelper; fields render through the standardForm.Fieldchain and participate in the existing submit + validation flow.displays[]targets detail-page sections: keyed by fieldid, an entry adds a read-only row (unknown id), replaces a built-in field’s render (matching id +component), or removes it (matching id +component: null).listextends the model’s list table: add/override columns by id, hide viaviewDefaults.columnVisibility, reorder viaviewDefaults.columnOrder.- Everything is typed against the panel-generated
CustomFieldsRegistry— validmodel,zone, and built-in field ids autocomplete; an invalid target failstsc.
Add a page
Create the route file
Create a
page.tsx inside src/routes/ and export a default React component. The route is determined by the file path:src/routes/reviews/page.tsx
Add the config export for navigation
A sidebar item is generated only when the page exports a
Route files may also export a
config with a label. Pages without one are still routed — they just don’t appear in the menu.| Property | Type | Description |
|---|---|---|
label | string | Required. Text shown in the sidebar menu |
icon | ComponentType | Icon component (e.g. from @medusajs/icons) |
rank | number | Sort order — lower numbers appear first |
nested | string | Parent path for nested menu items |
translationNs | string | i18n namespace for the label |
public | boolean | If true, the route is accessible without authentication |
loader (React Router data loader) and handle (route metadata) alongside the default component.Matching paths replace, new paths append. If your route’s path matches a built-in page (e.g.
src/routes/products/page.tsx → /products), your page replaces the built-in one. Any other path is added alongside the built-in routes. Delete the file and the built-in page returns. To change part of a built-in page without owning it, prefer a widget or a custom field.Routing conventions
File paths map to URL routes automatically:| File path | Route | Description |
|---|---|---|
src/routes/page.tsx | / | Root page |
src/routes/reviews/page.tsx | /reviews | Static segment |
src/routes/reviews/[id]/page.tsx | /reviews/:id | Dynamic segment |
src/routes/reviews/[[id]]/page.tsx | /reviews/:id? | Optional dynamic segment |
src/routes/search/[*]/page.tsx | /search/* | Catch-all |
src/routes/(settings)/page.tsx | Route grouping | Groups routes without adding a URL segment |
src/routes/dashboard/@sidebar/page.tsx | Parallel route | Renders alongside parent |
Branding
Setname and logo in the plugin options to customize the sidebar header:
vite.config.ts
Internationalization
FAQ
Can I use Medusa's defineWidgetConfig and widget zones?
Can I use Medusa's defineWidgetConfig and widget zones?
Yes.
defineWidgetConfig is re-exported from @mercurjs/dashboard-sdk and drives file-based widgets under src/widgets/. The difference from Medusa is the before | after placement suffix on the zone id and that each panel ships its own typed zone set. See Add a widget.How do I add a field to a built-in form or detail page?
How do I add a field to a built-in form or detail page?
Use
defineCustomFieldsConfig in src/custom-fields/<model>.tsx — forms[] adds validated fields to create/edit forms, and displays[] adds/replaces/removes fields in detail sections. See Extend forms and tables.How do I change just one part of a built-in page?
How do I change just one part of a built-in page?
For a spot inside the page (a banner, an extra field, a column), use a widget or a custom field — the rest of the page keeps all its behavior.
Is there a mercur.config.ts file?
Is there a mercur.config.ts file?
No — all panel configuration is passed inline to
mercurDashboardPlugin() in vite.config.ts. If you’ve seen references to a separate config file, they’re outdated.Does this apply to both the admin panel and the vendor portal?
Does this apply to both the admin panel and the vendor portal?
Both panels use the same SDK and conventions, and a file only targets the panel it lives in. Which built-in zones, models, and nav ids exist differs per panel (each ships its own
extension-targets.d.ts). Today the widget zones and product custom fields are mounted in the vendor portal; navigation overrides work in both.Next steps
Add a widget
Inject a component at a built-in zone with
defineWidgetConfig.Extend forms and tables
Add fields and columns with
defineCustomFieldsConfig.Customize navigation
Reorder, hide, and re-parent sidebar items.
Add a custom panel page
Your first drop-in route, end to end.