Skip to main content
This is the technical contract for the panel extension API. Where the Extend forms and tables, Add a widget, and Customize navigation tutorials walk through building an extension, this page documents the exact config shape of each helper and — most importantly — the props each component you pass in receives at render time.
Separate apps, no surface field. Admin (@mercurjs/admin, port 7000) and vendor (@mercurjs/vendor, port 7001) are separate Vite apps. A file dropped under a panel’s src/ targets that panel — the folder you author in is the surface. The helpers are the same in both; import them from @mercurjs/dashboard-sdk (config helpers) and @mercurjs/dashboard-shared (createFormHelper).

Typed targets (register once)

Zone ids, nav item ids, models, and built-in field ids are typed per panel from a generated extension-targets.d.ts. Reference it once per host app so every extension file type-checks with no per-file import:
apps/vendor/src/extension-targets.d.ts
/// <reference types="@mercurjs/vendor/extension-targets" />
apps/admin-test/src/extension-targets.d.ts
/// <reference types="@mercurjs/admin/extension-targets" />
A wrong zone, model, or nav id fails tsc (bun run lint) rather than silently no-op’ing at runtime.

defineWidgetConfig

A widget is a React component attached to a named zone on a built-in page. The placement (before | after | replace) is the last segment of the zone id.
apps/vendor/src/widgets/product-list-banner.tsx
import { defineWidgetConfig } from "@mercurjs/dashboard-sdk"

export const config = defineWidgetConfig({
  zone: "product.list.before", // WidgetZoneId — <domain>.<view>[.<slot>].<before|after|replace>
})

export default ProductListBanner

Config

FieldTypeDescription
zoneWidgetZoneId | WidgetZoneId[]Target zone(s). Placement is the suffix. Multiple widgets stack in order.
idstring (optional)Stable id; derived from the file path at build time when omitted.

Component props

The widget component receives a single prop:
PropTypeDescription
dataunknownThe zone’s contextual entity — e.g. the loaded product on a product.detail.* zone. Undefined on list/public zones that have no single entity.
const ProductDetailNote = ({ data }: { data?: HttpTypes.AdminProduct }) => (
  <Container>{data?.title}</Container>
)
The public login.logo / login.before / login.after zones render before authentication and receive no data.

defineNavigationConfig

Reorder, hide, relabel, or re-parent built-in sidebar items. Single host-owned file — blocks cannot contribute nav overrides.
apps/vendor/src/_navigation.ts
import { defineNavigationConfig } from "@mercurjs/dashboard-sdk"

export default defineNavigationConfig({
  items: [
    { id: "orders", rank: 0 },
    { id: "price-lists", hidden: true },
    { id: "payouts", label: "settlements" },
    { id: "categories", nested: null, rank: 1 },
    { id: "campaigns", nested: "orders" },
  ],
})
FieldTypeDescription
idNavItemIdBuilt-in item to override (top-level or nested). Typed against the panel registry.
ranknumber (optional)Order within the item’s parent (or among top-level items).
hiddenboolean (optional)Remove from the sidebar (route may still be directly reachable).
labelstring (optional)i18n key or literal replacing the item’s label.
iconComponentType (optional)Icon component replacing the item’s icon (from @medusajs/icons).
nestedNavParentId | null (optional)Re-parent under a built-in parent id; null promotes a nested item to top level.
There is no component prop here — navigation overrides reshape existing items only.

defineCustomFieldsConfig

The model-scoped surface. One file per model adds form fields, section displays, section actions, and list-table columns. Import createFormHelper from @mercurjs/dashboard-shared to turn a Zod schema into an input type + validation.
apps/vendor/src/custom-fields/product.tsx
import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"
import { createFormHelper } from "@mercurjs/dashboard-shared"

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

export default defineCustomFieldsConfig({
  model: "product",
  link: "brand", // string | string[] — module link(s) fetched alongside the entity
  forms: [/* ... */],
  displays: [/* ... */],
  list: {/* ... */},
})

Top-level config

FieldTypeDescription
modelCustomFieldModelTarget model (start with "product"). Typed against CustomFieldsRegistry.
linkstring | string[]Module link(s) fetched alongside the entity; their data is available to columns and displays via the +link.* fetch.
formsCustomFormEntry[]Fields injected into built-in create/edit/onboarding forms.
displaysCustomDisplayEntry[]Field replace/remove/add + ActionMenu actions on detail sections.
listCustomListExtensionColumns, bulk actions, filters, view defaults on the model’s list table.

forms[] — form fields

forms: [
  {
    zone: "edit",         // CustomFormZone — "create" | "edit" | "organize" | "attributes" | "onboarding"
    tab: "general",       // TabbedForm tab id, or wizard step id for zone: "onboarding"
    fields: {
      erp_id: form.define({
        validation: form.string().nullish(), // Zod → input type + validation
        defaultValue: (data) => (data?.metadata?.erp_id as string) ?? "",
        label: "ERP ID",
        description: "External system identifier",
        placeholder: "ERP-000",
        component: MyErpInput, // optional custom render
      }),
    },
  },
]
Each field is a CustomFormField:
FieldTypeDescription
validationZod schemaDrives both the default input type and validation.
defaultValueunknown | ((data) => unknown)Static value or a resolver from the loaded entity.
labelstring (optional)Field label.
descriptionstring (optional)Help text below the input.
placeholderstring (optional)Input placeholder.
componentComponentType (optional)Custom render; falls back to a default input for the Zod type.
Form-field component receives no props. It is rendered as <Component /> inside the field’s additional_data.<field> React Hook Form context. Read and write the value with RHF’s useFormContext() / useController(), and render through the mandated Form.Field → Form.Item chain — do not use a raw Controller. Values live in form state under additional_data; in the MVP, product custom fields persist onto the product’s metadata.

displays[] — detail sections

displays: [
  {
    zone: "general", // CustomFieldsRegistry displayZones — an existing detail section id
    fields: [
      { id: "erp_id", component: ErpRow },           // ADD (unknown id → new read-only row)
      { id: "status", component: BrandedStatusBadge }, // REPLACE a built-in field's render
      { id: "created_by", component: null },           // REMOVE a built-in field
    ],
    actions: [
      { rank: 0, component: SyncErpAction },            // add to the section's ActionMenu
    ],
  },
]
fields[] is CustomDisplayField:
FieldTypeDescription
iddisplayFieldIds | (string & {})Built-in field id (autocompletes) → replace/remove; any other string → add a new row.
componentComponentType<{ data? }> | nullRender component, or null to remove a built-in field.
Display field component props:
PropTypeDescription
dataunknownThe loaded detail entity, including any linked module data. If the config declares link: "brand", read it off data.brand here.
// with `link: "brand"` on the config, the linked module data rides on `data`
const BrandRow = ({ data }: { data?: { brand?: { name: string } } }) => (
  <Text>{data?.brand?.name}</Text>
)
actions[] is SectionAction (the same shape as list bulkActions):
FieldTypeDescription
ranknumber (optional)Position within the section’s ActionMenu.
componentComponentType<{ data? }>Owns its own label, icon, group placement, and onClick.
Section-action component props:
PropTypeDescription
dataunknownThe loaded detail entity, including any linked module data (e.g. data.brand).

list — list table

list: {
  columns: [
    { id: "title", component: ({ value }) => <strong>{value}</strong> }, // override a cell
    { id: "brand_name", header: "Brand", component: ({ row }) => row.brand?.name }, // add (from link)
  ],
  bulkActions: [{ rank: 0, component: ArchiveBulkAction }],
  filters: [/* add / remove list filters */],
  viewDefaults: {
    columnVisibility: { created_at: false }, // hide a column
    columnOrder: ["title", "sku", "erp_id"],
  },
}
columns[] is CustomColumn:
FieldTypeDescription
idstringColumn id — matches a built-in column to override, or adds a new one.
headerstring (optional)Column header text (for added columns).
componentComponentType<{ row?, value? }> (optional)Cell renderer.
Column component props:
PropTypeDescription
rowunknownThe full row entity (including linked module data).
valueunknownThe cell value for this column id, when derivable.
bulkActions[] is SectionAction ({ rank?, component }, component props { data? }).
Bulk-action rendering is deferred in the MVP. bulkActions are accepted and surfaced by the config but not yet mounted into the list toolbar.
Vendor product list is field-constrained. The vendor product list must use the curated fields from useProductTableQuery — the SDK merges link fetches with the +/- convention, never bare fields, or the list 500s. You never hand-write the field list; the link declaration drives it.

createFormHelper

createFormHelper<T>() returns Medusa’s zod surface for describing field validation and value types:
const form = createFormHelper<ProductWithMeta>()

form.define({ validation, defaultValue?, label?, description?, placeholder?, component? })
form.string() / form.number() / form.boolean() / form.date()
form.array() / form.object() / form.null() / form.nullable() / form.coerce
The generated registry types the target (which zone/tab/field ids exist); the Zod validation types the value. The two compose — codegen types the address, Zod types the payload.

Persistence

The MVP is a UI surface only. Custom fields render, validate, and display through the built-in forms/sections/tables — there is no generic core-side write path. For product, values are submitted under additional_data and persisted onto metadata. To store data for other models, wire your own route/workflow or use the backend Custom Fields module.

Extend forms and tables

Step-by-step build with defineCustomFieldsConfig.

Add a widget

Inject a component into a zone.

Customize navigation

Reorder and hide sidebar items.

Custom Fields module

The backend storage layer.