Skip to main content
Every page in the admin and vendor panels is exported as a compound component — a root plus named slots like Header, HeaderActions, and DataTable. That means you never fork a page to change one part of it: you drop a page.tsx at the same route path, render the original page, and swap only the slot you care about. Everything you don’t touch keeps its default behavior — data fetching, filters, pagination, i18n, all of it.
This is not Medusa’s widget system. Medusa’s admin lets you inject widgets into predefined zones around a page. Mercur takes a different approach: pages are React compound components you re-compose directly. There are no injection zones and no defineWidgetConfig — you get the actual page component and decide what renders inside it. See Extending Panels for the full comparison.

What you’ll build

A vendor portal /products page with Import and Export buttons added next to the built-in Create button — while the table, search, filters, and everything else stay exactly as shipped. This is the same technique the official product-import-export block uses.

Slot anatomy

Built-in pages are exported from the /pages subpath of each panel package:
import { ProductListPage } from "@mercurjs/vendor/pages"   // vendor portal
import { CustomerListPage } from "@mercurjs/admin/pages"   // admin panel
Each list page exposes the same family of slots:
SlotRenders
TableThe Container shell holding header + table
HeaderThe title/actions row
HeaderTitleHeading and subtitle
HeaderActionsThe action button cluster
HeaderCreateButtonThe built-in Create button
DataTableThe wired data table — fetching, columns, filters, pagination
Detail pages expose section slots instead (e.g. CustomerDetailPage.Main, CustomerDetailPage.Sidebar, CustomerDetailPage.MainGeneralSection).

Override the page

1

Drop a route file at the same path

Create the file in your vendor app at the path that matches the built-in route. /products maps to src/routes/products/page.tsx:
apps/vendor/src/routes/products/page.tsx
import { Link } from "react-router-dom"
import { Button } from "@medusajs/ui"
import { ArrowDownTray, ArrowUpTray } from "@medusajs/icons"
import { ProductListPage } from "@mercurjs/vendor/pages"

export default function ProductsWithImportExport() {
  return (
    <ProductListPage>
      <ProductListPage.Table>
        <ProductListPage.Header>
          <ProductListPage.HeaderTitle />
          <ProductListPage.HeaderActions>
            <Button size="small" variant="secondary" asChild>
              <Link to="import">
                <ArrowUpTray />
                Import
              </Link>
            </Button>
            <Button size="small" variant="secondary" asChild>
              <Link to="export">
                <ArrowDownTray />
                Export
              </Link>
            </Button>
            <ProductListPage.HeaderCreateButton />
          </ProductListPage.HeaderActions>
        </ProductListPage.Header>
        <ProductListPage.DataTable />
      </ProductListPage.Table>
    </ProductListPage>
  )
}
2

Reuse the slots you don't change

Note what happened in that file: HeaderTitle, HeaderCreateButton, and DataTable are the originals, rendered untouched. Only HeaderActions gained two buttons. You re-compose top-down — render the page, re-declare only the branch you’re changing, and keep original slots for everything inside it.
3

Reload the panel

The dashboard SDK picks the file up automatically (adding or removing a route file triggers a full reload in dev). Because the path matches a built-in route, your page replaces the built-in one — no configuration, no registration.
When a custom route’s path matches a built-in route, your page replaces the built-in one. Routes at new paths are appended instead. That one rule is the entire override mechanism.

How defaults work

If you render a compound page with no children, it renders its full default composition — so <ProductListPage /> alone is identical to the built-in page. Every page root follows the pattern:
Children.count(children) > 0 ? children : <DefaultComposition />

Verify

  1. Run your project (bun run dev) and open the vendor portal.
  2. Navigate to Products — the list should look identical to before, plus Import and Export buttons in the header.
  3. Search, filter, and paginate the table — all built-in behavior must still work, since DataTable is untouched.
  4. Delete your src/routes/products/page.tsx and reload — the built-in page comes back. Nothing was forked.

FAQ

Import the page from @mercurjs/vendor/pages or @mercurjs/admin/pages and let your editor’s autocomplete list the attached members — every slot is a static property on the page component. The naming is consistent across pages: list pages expose Table/Header/HeaderTitle/HeaderActions/HeaderCreateButton/DataTable; detail pages expose Main/Sidebar plus one property per section.
Yes — re-composition is declarative. Anything you don’t render doesn’t appear: re-declare HeaderActions with only your own buttons and omit HeaderCreateButton.
DataTable is a single slot — you either keep it wholesale or replace it with your own table. For a different column set, replace the slot with your own component built on the shared DataTable/useDataTable primitives from the panel package.

Next steps

Replace panel components

Swap global chrome like the sidebar or topbar instead of a single page.

Add a custom API route

Back your custom page with a typed endpoint of your own.