Skip to main content
Both the admin panel and the vendor portal run on the same SDK, @mercurjs/dashboard-sdk. You customize them by dropping files under src/. Customization is file-based and convention-driven. You add pages, widgets, and field extensions by placing files under src/, and you shape 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, such as defineWidgetConfig, defineRouteConfig, defineCustomFieldsConfig, and createFormHelper, all exist here, re-exported from @mercurjs/dashboard-sdk (without Medusa’s unstable_ prefix). Two things differ. 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. Each file handles one concern and is 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, or add fields in detail sections, and add columns to list tables (defineCustomFieldsConfig plus createFormHelper).
  • Pages: add a brand-new screen with a drop-in page.tsx route.
Additive tools leave the rest of the built-in page completely intact, including data fetching, filters, pagination, and i18n. A widget, a nav override, or a custom field changes only the spot you target.

Choose your extension mechanism

Use this 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.

Set up

All configuration lives in the panel app’s Vite config. There is no separate mercur.config.ts.
1

Register the plugin

Add 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
Projects created with create-mercur-app ship with this already wired for both panels.
2

Pass environment values explicitly

The plugin doesn’t read .env itself. Load environment variables in vite.config.ts (for example with Vite’s loadEnv) and pass them into the plugin options, as the starter template does with backendUrl.
3

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 @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
For the admin app, reference @mercurjs/admin/extension-targets instead. 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.
4

Restart after config changes

Options are applied at build time through virtual modules. Adding or removing routes, widgets, and custom-field files hot-reloads automatically, but changes to the plugin options (in vite.config.ts) require a dev-server restart.

Configuration options

Widgets

A widget injects a React component at a named zone on a built-in page. Drop a file under src/widgets/, export the component as the default and a config built with defineWidgetConfig.
src/widgets/product-list-banner.tsx
The zone id reads <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.
Zones mounted today (vendor portal): The full set of valid zones is typed as 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. Custom drop-in routes place their own sidebar item via defineRouteConfig (see Add a page). To reshape the built-in sidebar items, author a single host-owned file, src/_navigation.ts.
src/_navigation.ts
  • id targets any built-in item, top-level routes or nested children, by its stable id, typed as NavItemId.
  • rank orders an item within its parent, hidden removes it from the sidebar, label and icon relabel it, and nested re-parents it (nested: null promotes to top level, typed against NavParentId).
  • 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.
Walk through it in Customize navigation.

Custom fields

A custom field adds validated fields to a model’s built-in create and edit forms, replaces, removes, or adds fields in its detail sections, and adds columns to its list table, all from one model-scoped file. Drop src/custom-fields/<model>.tsx and default-export a defineCustomFieldsConfig.
src/custom-fields/product.tsx
  • forms[] adds validated fields to a form zone (create, edit, organize, and so on). Input type and validation come from a Zod schema via createFormHelper. Fields render through the standard Form.Field chain and participate in the existing submit and validation flow.
  • displays[] targets detail-page sections, keyed by field id. An entry adds a read-only row (unknown id), replaces a built-in field’s render (matching id plus component), or removes it (matching id plus component: null).
  • list extends the model’s list table: add or override columns by id, hide via viewDefaults.columnVisibility, reorder via viewDefaults.columnOrder.
  • Everything is typed against the panel-generated CustomFieldsRegistry. Valid model, zone, and built-in field ids autocomplete, and an invalid target fails tsc.
Panel custom fields vs. the Custom Fields module. defineCustomFieldsConfig (this section) is a UI surface. It renders, validates, and displays fields in the panels. It does not create database columns. To store extra data on an entity, use the backend Custom Fields module, or wire your own API route or workflow. In the MVP, panel custom fields for product are submitted under additional_data and persisted onto the product’s metadata.
For the full walkthrough (forms, sections, and list columns), see Extend forms and tables.

Add a page

1

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
2

Add the config export for navigation

A sidebar item is generated only when the page exports a config with a label. Pages without one are still routed. They just don’t appear in the menu.Route files may also export a loader (React Router data loader) and handle (route metadata) alongside the default component.
3

Open it in the running panel

Start the dev server. The SDK picks the file up automatically. This example creates a /reviews route with a “Reviews” sidebar item. No route registration, no configuration file.
Matching paths replace, new paths append. If your route’s path matches a built-in page (for example src/routes/products/page.tsx maps to /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.

Branding

Set name and logo in the plugin options to customize the sidebar header.
vite.config.ts

Internationalization

1

Create translation resources

src/i18n/index.ts
2

Reference the namespace in your page config

src/routes/reviews/page.tsx
3

Set the default language

vite.config.ts

FAQ

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.
Use defineCustomFieldsConfig in src/custom-fields/<model>.tsx. forms[] adds validated fields to create and edit forms, and displays[] adds, replaces, or removes fields in detail sections. See Extend forms and tables.
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.
A sidebar item is only generated when the route file exports a config object with a label. Also check that the file is named exactly page.tsx (or .ts/.jsx/.js) under src/routes/ and has a default export. Files without one are skipped entirely.
Make sure the panel’s typed targets are registered with a single src/extension-targets.d.ts containing /// <reference types="@mercurjs/vendor/extension-targets" /> (or the admin equivalent). Without it, zone, model, and nav ids aren’t known to TypeScript. create-mercur-app ships this 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.
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, and 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.