Skip to main content
The Admin and Vendor panels share one design system. The three things you’ll actually do — style with @medusajs/ui, extend built-in screens with custom fields, and add new pages — all have an established shape and, importantly, a set of correct imports. This page is that short list. The full reference is the UI architecture.

Use @medusajs/ui — and only it

Never introduce a second UI library, and never restyle Medusa UI components with custom CSS. Build on the primitives; don’t work around them.
  • Components come from @medusajs/ui, icons from @medusajs/icons, and colours/spacing/type from Medusa UI tokens (text-ui-fg-*, bg-ui-bg-*, border-ui-border-*) — never hex, rgb(), or text-gray-500.
import { Container, Heading, Text, Button, Badge, StatusBadge, toast } from "@medusajs/ui"
import { PencilSquare, Trash, EllipsisHorizontal } from "@medusajs/icons"
  • A section is a Container with the standard shell — a divided card with a header row:
<Container className="divide-y p-0">
  <div className="flex items-center justify-between px-6 py-4">
    <Heading level="h2">Details</Heading>
    <Button size="small" variant="secondary">Edit</Button>
  </div>
  <div className="px-6 py-4">
    <Text size="small" className="text-ui-fg-subtle">Body</Text>
  </div>
</Container>

Extend built-in screens with custom fields

The primary way to customise an existing entity’s screens (product, order, customer…) is a custom-fields config — one file per model that contributes form fields, table columns, and read-only section fields. See Custom fields for the full backend↔frontend loop; here’s the frontend surface with the right imports.
The two imports you need — and where each lives:
import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"   // the config helper
import { createFormHelper } from "@mercurjs/dashboard-shared"        // typed form fields (zod)
defineCustomFieldsConfig is build-time config (SDK, zod-free); createFormHelper is the runtime form surface (dashboard-shared). Don’t cross them over.

Add form fields (edit / create)

Contribute inputs into a built-in form zone. Values submit under additional_data:
apps/vendor/src/custom-fields/product.tsx
import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"
import { createFormHelper } from "@mercurjs/dashboard-shared"

const form = createFormHelper<{ custom_fields?: { is_featured?: boolean } }>()

export default defineCustomFieldsConfig({
  model: "product",
  link: "custom_fields",
  forms: [
    {
      zone: "edit",
      fields: {
        is_featured: form.define({
          validation: form.boolean().optional(),
          label: "Featured",
          defaultValue: (data) => Boolean(data?.custom_fields?.is_featured),
        }),
      },
    },
  ],
})

Change the list table

Add or override a column (and add bulk actions) on the model’s built-in list:
list: {
  columns: [
    { id: "is_featured", header: "Featured", component: ({ row }) => (row.custom_fields?.is_featured ? "★" : "") },
  ],
},

Read-only fields in detail sections — e.g. surface (or change) a status

displays add read-only rows into an existing detail-page section, keyed by id (unknown id adds, built-in id replaces, component: null hides). A read-only field can render a StatusBadge, and a section action can trigger a status change through a mutation:
apps/vendor/src/custom-fields/product.tsx
import { StatusBadge, Button, toast } from "@medusajs/ui"

// inside defineCustomFieldsConfig(...)
displays: [
  {
    zone: "general",
    fields: [
      {
        id: "review_status",
        component: ({ data }) => (
          <div className="flex items-center justify-between px-6 py-4">
            <StatusBadge color={data.custom_fields?.approved ? "green" : "orange"}>
              {data.custom_fields?.approved ? "Approved" : "Pending"}
            </StatusBadge>
            <Button
              size="small"
              variant="secondary"
              onClick={async () => {
                await sdk.vendor.products.$id.mutate({
                  $id: data.id,
                  additional_data: { approved: true },
                })
                toast.success("Approved")
              }}
            >
              Approve
            </Button>
          </div>
        ),
      },
    ],
  },
],
Read-only displays are the idiomatic way to expose (and act on) an entity’s state — an approval flag, a moderation status, an internal tag — without rebuilding the detail page. The mutation still goes through the typed SDK and rides additional_data into a workflow hook, never a direct write.

Add a new page

A brand-new screen is one file: drop a page.tsx under the host app’s src/routes/. The SDK registers the route from the file path and builds the sidebar entry from an exported config.
apps/vendor/src/routes/reviews/page.tsx
import { Container, Heading } from "@medusajs/ui"
import { Star } from "@medusajs/icons"
import type { RouteConfig } from "@mercurjs/dashboard-sdk"

export const config: RouteConfig = {
  label: "Reviews",
  icon: Star,
}

export default function ReviewsPage() {
  return (
    <Container className="divide-y p-0">
      <div className="px-6 py-4">
        <Heading>Reviews</Heading>
      </div>
    </Container>
  )
}
Correct imports for a page: UI from @medusajs/ui, icons from @medusajs/icons, and the RouteConfig type from @mercurjs/dashboard-sdk. Dynamic segments use brackets — src/routes/reviews/[id]/page.tsx/reviews/:id. See Extending panels.

Compose a full page: layout, table, sections, edit

For a real screen you assemble the same primitives the built-in pages use — all re-exported from @mercurjs/dashboard-shared, so you import from one place instead of Medusa internals.
import {
  SingleColumnPage,
  TwoColumnPage,
  DataTable,
  useDataTable,
  SectionRow,
  RouteDrawer,
  Form,
  ActionMenu,
} from "@mercurjs/dashboard-shared"
import { Container, Heading, Text, Button, Input, toast } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"

Layout + list table

Pick a layout — SingleColumnPage for lists/simple pages, TwoColumnPage for a detail with a sidebar — and mount a DataTable inside the standard section shell. Build columns with createColumnHelper, wire the table with useDataTable, page size 20, and keepPreviousData for smooth pagination:
apps/vendor/src/routes/reviews/page.tsx
const columnHelper = createColumnHelper<Review>()

const columns = [
  columnHelper.accessor("title", { header: "Title" }),
  columnHelper.accessor("rating", { header: "Rating" }),
  columnHelper.display({
    id: "actions",
    cell: ({ row }) => (
      <ActionMenu groups={[{ actions: [{ label: "Edit", to: `${row.original.id}/edit` }] }]} />
    ),
  }),
]

export default function ReviewsPage() {
  const { reviews = [], count = 0, isLoading } = useReviews()
  const { table } = useDataTable({ data: reviews, columns, count, pageSize: 20, getRowId: (r) => r.id })

  return (
    <SingleColumnPage>
      <Container className="divide-y p-0">
        <div className="flex items-center justify-between px-6 py-4">
          <Heading>Reviews</Heading>
        </div>
        <DataTable table={table} columns={columns} count={count} pageSize={20} isLoading={isLoading} navigateTo={(row) => row.id} pagination search />
      </Container>
    </SingleColumnPage>
  )
}

General section (label / value rows)

On a detail page, a “general” section is a Container header row plus SectionRow label/value pairs — the canonical way Medusa renders read-only entity data:
<Container className="divide-y p-0">
  <div className="flex items-center justify-between px-6 py-4">
    <Heading>{review.title}</Heading>
    <ActionMenu groups={[{ actions: [{ label: "Edit", to: "edit" }] }]} />
  </div>
  <SectionRow title="Rating" value={`${review.rating} / 5`} />
  <SectionRow title="Status" value={review.approved ? "Approved" : "Pending"} />
</Container>
For a detail page with a sidebar, wrap sections in TwoColumnPage and place them under TwoColumnPage.Main / TwoColumnPage.Sidebar (each stacked with gap-y-3).

Edit page (drawer)

Quick edits live in a routed RouteDrawer with Form (React Hook Form + Zod). Gate the form until the entity has loaded, and use useRouteModal().handleSuccess() to close on save:
apps/vendor/src/routes/reviews/[id]/edit/page.tsx
export default function EditReviewPage() {
  return (
    <RouteDrawer>
      <RouteDrawer.Header>
        <RouteDrawer.Title asChild>
          <Heading>Edit review</Heading>
        </RouteDrawer.Title>
      </RouteDrawer.Header>
      {/* <EditReviewForm /> — RouteDrawer.Form + KeyboundForm, gated on !isPending && !!review */}
    </RouteDrawer>
  )
}
Import these primitives from @mercurjs/dashboard-shared, not from deep Medusa dashboard paths like ../../../components/table/data-table. The shared package is the public, stable surface; relative Medusa-internal imports are not available to consumer apps and break on upgrade.

Data only through the typed SDK

Never call fetch directly from a page. All HTTP goes through the typed SDK — sdk.admin.* in the admin panel, sdk.vendor.* in the vendor panel — wrapped in TanStack Query hooks.
src/hooks/api/reviews.tsx
import { useQuery } from "@tanstack/react-query"
import { sdk } from "../../lib/client"
import { queryKeysFactory } from "@mercurjs/dashboard-shared"

const reviewKeys = queryKeysFactory("reviews")

export const useReviews = (query?: Record<string, unknown>) =>
  useQuery({
    queryKey: reviewKeys.list(query),
    queryFn: () => sdk.vendor.reviews.query({ ...query }),
  })
Invalidate lists() / details() / detail(id) in mutations, throw on isError so the route ErrorBoundary catches it, and show a Skeleton while loading.

Checklist for panel work

  • Built only from @medusajs/ui + @medusajs/icons; Medusa UI tokens only, no custom CSS.
  • Extending an existing screen → a defineCustomFieldsConfig file (@mercurjs/dashboard-sdk) with createFormHelper (@mercurjs/dashboard-shared); forms submit under additional_data.
  • Read-only state (status/flags) surfaced via displays; changes go through the typed SDK + a workflow hook, not a direct write.
  • New screen → a page.tsx under src/routes/ with a typed RouteConfig; compose it from SingleColumnPage/TwoColumnPage, DataTable, SectionRow, and RouteDrawer — all imported from @mercurjs/dashboard-shared, never Medusa-internal paths.
  • Data via sdk.admin.* / sdk.vendor.* in TanStack Query hooks; no raw fetch; mutations invalidate the right keys.
  • Every visible string translated; every interactive element has a data-testid.