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

# How to Extend the Panels

> Add pages, widgets, navigation overrides, and form and table fields to the admin and vendor panels without forking.

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.

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

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

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

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

| 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, or remove a field in a detail section | A **custom field** (`defineCustomFieldsConfig`)    | Validated field wired into the existing form or 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, or 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, and 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` (for example 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` 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.
  </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

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

```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 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**):

| 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` 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](/rc/resources/tutorials/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`.

```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`, 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`.

<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 or workflow. In the MVP, panel custom fields for `product` are submitted under `additional_data` and persisted onto the product's `metadata`.
</Warning>

For the full walkthrough (forms, sections, and list columns), see [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 (for example 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 (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.
</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 and edit forms, and `displays[]` adds, replaces, or 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 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.
  </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, and 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>
