> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mercurjs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Panel Extension API

> The file-based helpers for extending the admin and vendor panels — defineWidgetConfig, defineNavigationConfig, defineCustomFieldsConfig, createFormHelper — and the exact props passed to every component you supply.

This is the technical contract for the panel extension API.
Where the [Extend forms and tables](/rc/resources/tutorials/extend-forms-and-tables),
[Add a widget](/rc/resources/tutorials/add-a-widget), and
[Customize navigation](/rc/resources/tutorials/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**.

<Info>
  **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`).
</Info>

## 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:

```typescript apps/vendor/src/extension-targets.d.ts theme={null}
/// <reference types="@mercurjs/vendor/extension-targets" />
```

```typescript apps/admin-test/src/extension-targets.d.ts theme={null}
/// <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.

```tsx apps/vendor/src/widgets/product-list-banner.tsx theme={null}
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

| Field  | Type                             | Description                                                               |
| ------ | -------------------------------- | ------------------------------------------------------------------------- |
| `zone` | `WidgetZoneId \| WidgetZoneId[]` | Target zone(s). Placement is the suffix. Multiple widgets stack in order. |
| `id`   | `string` (optional)              | Stable id; derived from the file path at build time when omitted.         |

### Component props

The widget component receives a single prop:

| Prop   | Type      | Description                                                                                                                                     |
| ------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `data` | `unknown` | The zone's contextual entity — e.g. the loaded product on a `product.detail.*` zone. Undefined on list/public zones that have no single entity. |

```tsx theme={null}
const ProductDetailNote = ({ data }: { data?: HttpTypes.AdminProduct }) => (
  <Container>{data?.title}</Container>
)
```

<Note>
  The public `login.logo` / `login.before` / `login.after` zones render before
  authentication and receive no `data`.
</Note>

***

## `defineNavigationConfig`

Reorder, hide, relabel, or re-parent **built-in** sidebar items. Single
host-owned file — blocks cannot contribute nav overrides.

```ts apps/vendor/src/_navigation.ts theme={null}
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" },
  ],
})
```

### `NavItemOverride`

| Field    | Type                             | Description                                                                        |
| -------- | -------------------------------- | ---------------------------------------------------------------------------------- |
| `id`     | `NavItemId`                      | Built-in item to override (top-level or nested). Typed against the panel registry. |
| `rank`   | `number` (optional)              | Order within the item's parent (or among top-level items).                         |
| `hidden` | `boolean` (optional)             | Remove from the sidebar (route may still be directly reachable).                   |
| `label`  | `string` (optional)              | i18n key or literal replacing the item's label.                                    |
| `icon`   | `ComponentType` (optional)       | Icon component replacing the item's icon (from `@medusajs/icons`).                 |
| `nested` | `NavParentId \| 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.

```tsx apps/vendor/src/custom-fields/product.tsx theme={null}
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

| Field      | Type                   | Description                                                                                                           |
| ---------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `model`    | `CustomFieldModel`     | Target model (start with `"product"`). Typed against `CustomFieldsRegistry`.                                          |
| `link`     | `string \| string[]`   | Module link(s) fetched alongside the entity; their data is available to columns and displays via the `+link.*` fetch. |
| `forms`    | `CustomFormEntry[]`    | Fields injected into built-in create/edit/onboarding forms.                                                           |
| `displays` | `CustomDisplayEntry[]` | Field replace/remove/add + `ActionMenu` actions on detail sections.                                                   |
| `list`     | `CustomListExtension`  | Columns, bulk actions, filters, view defaults on the model's list table.                                              |

### `forms[]` — form fields

```tsx theme={null}
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`:

| Field          | Type                             | Description                                                    |
| -------------- | -------------------------------- | -------------------------------------------------------------- |
| `validation`   | Zod schema                       | Drives both the default input type and validation.             |
| `defaultValue` | `unknown \| ((data) => unknown)` | Static value or a resolver from the loaded entity.             |
| `label`        | `string` (optional)              | Field label.                                                   |
| `description`  | `string` (optional)              | Help text below the input.                                     |
| `placeholder`  | `string` (optional)              | Input placeholder.                                             |
| `component`    | `ComponentType` (optional)       | Custom render; falls back to a default input for the Zod type. |

<Warning>
  **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`.
</Warning>

### `displays[]` — detail sections

```tsx theme={null}
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`:

| Field       | Type                               | Description                                                                           |
| ----------- | ---------------------------------- | ------------------------------------------------------------------------------------- |
| `id`        | `displayFieldIds \| (string & {})` | Built-in field id (autocompletes) → replace/remove; any other string → add a new row. |
| `component` | `ComponentType<{ data? }> \| null` | Render component, or `null` to remove a built-in field.                               |

**Display field `component` props:**

| Prop   | Type      | Description                                                                                                                              |
| ------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `data` | `unknown` | The loaded detail entity, **including any `link`ed module data**. If the config declares `link: "brand"`, read it off `data.brand` here. |

```tsx theme={null}
// 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`):

| Field       | Type                       | Description                                               |
| ----------- | -------------------------- | --------------------------------------------------------- |
| `rank`      | `number` (optional)        | Position within the section's `ActionMenu`.               |
| `component` | `ComponentType<{ data? }>` | Owns its own label, icon, group placement, and `onClick`. |

**Section-action `component` props:**

| Prop   | Type      | Description                                                                           |
| ------ | --------- | ------------------------------------------------------------------------------------- |
| `data` | `unknown` | The loaded detail entity, **including any `link`ed module data** (e.g. `data.brand`). |

### `list` — list table

```tsx theme={null}
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`:

| Field       | Type                                         | Description                                                           |
| ----------- | -------------------------------------------- | --------------------------------------------------------------------- |
| `id`        | `string`                                     | Column id — matches a built-in column to override, or adds a new one. |
| `header`    | `string` (optional)                          | Column header text (for added columns).                               |
| `component` | `ComponentType<{ row?, value? }>` (optional) | Cell renderer.                                                        |

**Column `component` props:**

| Prop    | Type      | Description                                           |
| ------- | --------- | ----------------------------------------------------- |
| `row`   | `unknown` | The full row entity (including `link`ed module data). |
| `value` | `unknown` | The cell value for this column id, when derivable.    |

`bulkActions[]` is `SectionAction` (`{ rank?, component }`, component props `{ data? }`).

<Note>
  **Bulk-action rendering is deferred in the MVP.** `bulkActions` are accepted and
  surfaced by the config but not yet mounted into the list toolbar.
</Note>

<Warning>
  **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.
</Warning>

***

## `createFormHelper`

`createFormHelper<T>()` returns Medusa's zod surface for describing field
validation and value types:

```ts theme={null}
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

<Warning>
  **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](/rc/resources/customization/custom-fields).
</Warning>

## Related

<CardGroup cols={2}>
  <Card title="Extend forms and tables" href="/rc/resources/tutorials/extend-forms-and-tables">
    Step-by-step build with `defineCustomFieldsConfig`.
  </Card>

  <Card title="Add a widget" href="/rc/resources/tutorials/add-a-widget">
    Inject a component into a zone.
  </Card>

  <Card title="Customize navigation" href="/rc/resources/tutorials/customize-navigation">
    Reorder and hide sidebar items.
  </Card>

  <Card title="Custom Fields module" href="/rc/references/modules/custom-fields">
    The backend storage layer.
  </Card>
</CardGroup>
