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

# Extending Panels

> Add pages, inject widgets, customize navigation, and extend forms and tables in the admin and vendor panels.

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.

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

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

<Note>
  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.
</Note>

## 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](/rc/resources/tutorials/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](/rc/resources/tutorials/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](/rc/resources/tutorials/extend-forms-and-tables)                        |
| Reorder / hide / relabel / re-parent sidebar items                                | The **navigation** file (`defineNavigationConfig`) | One host-owned `_navigation.ts` — see [Customize navigation](/rc/resources/tutorials/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](/rc/resources/tutorials/custom-panel-page)                    |
| Reuse a feature across projects                                                   | A [block](/rc/learn/blocks)                        | Ships 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`.

<Steps>
  <Step title="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:

    ```typescript vite.config.ts theme={null}
    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.
  </Step>

  <Step title="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`.
  </Step>

  <Step title="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`:

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

  <Step title="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.
  </Step>
</Steps>

### 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 under `src/widgets/`, export the component as the default and a `config` built with `defineWidgetConfig`:

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

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](/rc/resources/tutorials/add-a-widget).

## Navigation

Custom drop-in routes place their own sidebar item via `defineRouteConfig` (see [Add a page](#add-a-page)). To reshape the **built-in** sidebar items, author a single host-owned file, `src/_navigation.ts`:

```ts src/_navigation.ts theme={null}
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](/rc/resources/tutorials/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`:

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

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

Full walkthrough (forms, sections, list columns): [Extend forms and tables](/rc/resources/tutorials/extend-forms-and-tables).

## Add a page

<Steps>
  <Step title="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:

    ```tsx src/routes/reviews/page.tsx theme={null}
    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>
    }
    ```
  </Step>

  <Step title="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.

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

    Route files may also export a `loader` (React Router data loader) and `handle` (route metadata) alongside the default component.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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

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

Set `name` and `logo` in the plugin options to customize the sidebar header:

```typescript vite.config.ts theme={null}
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

<Steps>
  <Step title="Create translation resources">
    ```typescript src/i18n/index.ts theme={null}
    export default {
      en: {
        reviews: {
          title: "Reviews",
          description: "Manage product reviews",
        },
      },
      de: {
        reviews: {
          title: "Bewertungen",
          description: "Produktbewertungen verwalten",
        },
      },
    }
    ```
  </Step>

  <Step title="Reference the namespace in your page config">
    ```tsx src/routes/reviews/page.tsx theme={null}
    export const config: RouteConfig = {
      label: "reviews.title",
      icon: Star,
      translationNs: "reviews",
    }
    ```
  </Step>

  <Step title="Set the default language">
    ```typescript vite.config.ts theme={null}
    mercurDashboardPlugin({
      medusaConfigPath: '../../packages/api/medusa-config.ts',
      name: 'My Marketplace',
      i18n: {
        defaultLanguage: 'en',
      },
    })
    ```
  </Step>
</Steps>

## FAQ

<AccordionGroup>
  <Accordion title="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](/rc/resources/tutorials/add-a-widget).
  </Accordion>

  <Accordion title="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](/rc/resources/tutorials/extend-forms-and-tables).
  </Accordion>

  <Accordion title="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](#widgets) or a [custom field](#custom-fields) — the rest of the page keeps all its behavior.
  </Accordion>

  <Accordion title="Why doesn't my page show up in the sidebar?">
    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.
  </Accordion>

  <Accordion title="Why does my widget / custom field / nav override not type-check?">
    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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Add a widget" href="/rc/resources/tutorials/add-a-widget">
    Inject a component at a built-in zone with `defineWidgetConfig`.
  </Card>

  <Card title="Extend forms and tables" href="/rc/resources/tutorials/extend-forms-and-tables">
    Add fields and columns with `defineCustomFieldsConfig`.
  </Card>

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

  <Card title="Add a custom panel page" href="/rc/resources/tutorials/custom-panel-page">
    Your first drop-in route, end to end.
  </Card>
</CardGroup>
