Skip to main content
Both the admin panel and vendor portal use the same SDK (@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.tsx route.
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…UseWhy it’s the right tool
Show a banner, panel, or CTA on a built-in pageA 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 sectionA 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 tableThe list block of a custom field fileModel-scoped column extension — see Extend forms and tables
Reorder / hide / relabel / re-parent sidebar itemsThe navigation file (defineNavigationConfig)One host-owned _navigation.ts — see Customize navigation
Add a new screen or featureA drop-in page.tsx routeNew URL, auto-registered, sidebar entry via config — see Add a page
Reuse a feature across projectsA blockShips API + admin + vendor files installable with mercurjs add

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
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { mercurDashboardPlugin } from '@mercurjs/dashboard-sdk'

export default defineConfig({
  plugins: [
    react(),
    mercurDashboardPlugin({
      medusaConfigPath: '../../packages/api/medusa-config.ts',
      name: 'My Marketplace',
      logo: 'https://example.com/logo.svg',
    }),
  ],
})
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 (e.g. 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
/// <reference types="@mercurjs/vendor/extension-targets" />
(For the admin app, reference @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.
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

OptionTypeDescription
medusaConfigPathstringRequired. Path to your API’s medusa-config.ts, relative to the panel project root
backendUrlstringMedusa backend URL (default: http://localhost:9000)
vendorUrlstringAbsolute vendor portal URL including its path prefix
namestringApplication name shown in the sidebar
logostringURL to a logo image
i18nobjectInternationalization settings ({ defaultLanguage })
enableSellerRegistrationbooleanEnable the public seller registration flow (vendor portal)
imageLimitnumberMax 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 under src/widgets/, export the component as the default and a config built with defineWidgetConfig:
src/widgets/product-list-banner.tsx
import { defineWidgetConfig } from "@mercurjs/dashboard-sdk"
import { Container, Text } from "@medusajs/ui"

export const config = defineWidgetConfig({
  zone: "product.list.before",
})

export default function ProductListBanner() {
  return (
    <Container className="mb-2">
      <Text size="small" className="text-ui-fg-subtle">
        Tip: bulk-import products from the Products menu.
      </Text>
    </Container>
  )
}
The zone id is <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):
Zone idWhere it renders
product.list.before / .afterVendor product list page
login.logo.* / login.before.* / login.after.*The public login screen (rendered before authentication)
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
import { defineNavigationConfig } from "@mercurjs/dashboard-sdk"

export default defineNavigationConfig({
  items: [
    { id: "orders", rank: 0 },              // pin to the top
    { id: "price-lists", hidden: true },    // hide a built-in item
    { id: "payouts", label: "Settlements" }, // relabel
    { id: "campaigns", nested: "orders" },   // re-parent under Orders
    { id: "categories", nested: null, rank: 1 }, // promote a nested item to top level
  ],
})
  • 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 / icon relabel it; 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

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. Drop src/custom-fields/<model>.tsx and default-export a defineCustomFieldsConfig:
src/custom-fields/product.tsx
import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"
import { createFormHelper } from "@mercurjs/dashboard-shared"
import { Text } from "@medusajs/ui"

const form = createFormHelper<{ metadata?: Record<string, unknown> }>()

export default defineCustomFieldsConfig({
  model: "product",
  forms: [
    {
      zone: "edit",
      fields: {
        erp_id: form.define({
          validation: form.string().optional(), // Zod → input type + validation
          label: "ERP ID",
          description: "External system identifier",
          placeholder: "ERP-000",
        }),
      },
    },
  ],
  displays: [
    {
      zone: "general",
      fields: [
        { id: "erp_id", component: ({ data }) => <Text className="px-6 py-4">ERP: {String(data.metadata?.erp_id ?? "-")}</Text> }, // ADD
        { id: "subtitle", component: null },  // REMOVE a built-in field
        { id: "handle", component: BrandedHandle }, // REPLACE a built-in field's render
      ],
    },
  ],
  list: {
    columns: [{ id: "erp_id", header: "ERP", component: ({ row }) => String(row.metadata?.erp_id ?? "-") }],
    viewDefaults: {
      columnVisibility: { collection: false }, // hide a built-in column
      columnOrder: ["product", "erp_id", "status"],
    },
  },
})
  • forms[] adds validated fields to a form zone (create / edit / organize / …). 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 + 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 + component), or removes it (matching id + component: null).
  • list extends the model’s list table: add/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; 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/workflow. In the MVP, panel custom fields for product are submitted under additional_data and persisted onto the product’s metadata.
Full walkthrough (forms, sections, list columns): 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
import { Star } from "@medusajs/icons"
import type { RouteConfig } from "@mercurjs/dashboard-sdk"

export const config: RouteConfig = {
  label: "Reviews",
  icon: Star,
  rank: 10,
}

export default function ReviewsPage() {
  return <div>Reviews</div>
}
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.
PropertyTypeDescription
labelstringRequired. Text shown in the sidebar menu
iconComponentTypeIcon component (e.g. from @medusajs/icons)
ranknumberSort order — lower numbers appear first
nestedstringParent path for nested menu items
translationNsstringi18n namespace for the label
publicbooleanIf true, the route is accessible without authentication
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 (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 pathRouteDescription
src/routes/page.tsx/Root page
src/routes/reviews/page.tsx/reviewsStatic segment
src/routes/reviews/[id]/page.tsx/reviews/:idDynamic segment
src/routes/reviews/[[id]]/page.tsx/reviews/:id?Optional dynamic segment
src/routes/search/[*]/page.tsx/search/*Catch-all
src/routes/(settings)/page.tsxRoute groupingGroups routes without adding a URL segment
src/routes/dashboard/@sidebar/page.tsxParallel routeRenders alongside parent

Branding

Set name and logo in the plugin options to customize the sidebar header:
vite.config.ts
mercurDashboardPlugin({
  medusaConfigPath: '../../packages/api/medusa-config.ts',
  name: 'WeTest',
  logo: 'https://ui-avatars.com/api/?name=WeTest&background=18181B&color=fff&size=200&bold=true&format=svg',
})

Internationalization

1

Create translation resources

src/i18n/index.ts
export default {
  en: {
    reviews: {
      title: "Reviews",
      description: "Manage product reviews",
    },
  },
  de: {
    reviews: {
      title: "Bewertungen",
      description: "Produktbewertungen verwalten",
    },
  },
}
2

Reference the namespace in your page config

src/routes/reviews/page.tsx
export const config: RouteConfig = {
  label: "reviews.title",
  icon: Star,
  translationNs: "reviews",
}
3

Set the default language

vite.config.ts
mercurDashboardPlugin({
  medusaConfigPath: '../../packages/api/medusa-config.ts',
  name: 'My Marketplace',
  i18n: {
    defaultLanguage: 'en',
  },
})

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>.tsxforms[] adds validated fields to create/edit forms, and displays[] adds/replaces/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 — a single src/extension-targets.d.ts with /// <reference types="@mercurjs/vendor/extension-targets" /> (or the admin equivalent). Without it, zone/model/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; 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.