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

# Frontend Patterns

> Build Admin and Vendor panel UI with the shared design system: @medusajs/ui, custom-field extensions, and new pages, with the correct imports.

The Admin and Vendor panels share one design system. You do three things with it: style with `@medusajs/ui`, extend built-in screens with custom fields, and add new pages. Each has an established shape and a set of correct imports. This page is that short list. For the full reference, see the [panel extensions reference](/references/panel-extensions/overview).

## Use @medusajs/ui, and only it

Components come from `@medusajs/ui`, icons from `@medusajs/icons`, and colours, spacing, and type from Medusa UI tokens (`text-ui-fg-*`, `bg-ui-bg-*`, `border-ui-border-*`). Never use hex, `rgb()`, or `text-gray-500`.

```tsx theme={null}
import { Container, Heading, Text, Button, Badge, StatusBadge, toast } from "@medusajs/ui"
import { PencilSquare, Trash, EllipsisHorizontal } from "@medusajs/icons"
```

<Warning>
  Never introduce a second UI library, and never restyle Medusa UI components with custom CSS. Build on the primitives. Do not work around them.
</Warning>

A section is a `Container` with the standard shell: a divided card with a header row.

```tsx theme={null}
<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](/rc/resources/best-practices/custom-fields) for the full backend and frontend loop. This section covers the frontend surface with the right imports.

You need two imports, and each lives in a different package.

```tsx theme={null}
import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"   // the config helper
import { createFormHelper } from "@mercurjs/dashboard-shared"        // typed form fields (zod)
```

<Note>
  `defineCustomFieldsConfig` is build-time config (SDK, zod-free). `createFormHelper` is the runtime form surface (dashboard-shared). Do not cross them over.
</Note>

### Add form fields (edit / create)

Contribute inputs into a built-in form `zone`. Values submit under `additional_data`.

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

```tsx theme={null}
list: {
  columns: [
    { id: "is_featured", header: "Featured", component: ({ row }) => (row.custom_fields?.is_featured ? "★" : "") },
  ],
},
```

### Read-only fields in detail sections

Use `displays` to add read-only rows into an existing detail-page section, keyed by `id`. An unknown id adds a row, a built-in id replaces one, and `component: null` hides one. A read-only field can render a `StatusBadge`, and a section `action` can trigger a status change through a mutation.

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

<Tip>
  Read-only displays are the idiomatic way to expose an entity's state, such as an approval flag, a moderation status, or an internal tag, and to act on it without rebuilding the detail page. The mutation still goes through the typed SDK and rides `additional_data` into a [workflow hook](/rc/resources/best-practices/custom-fields#the-full-override-flow-additional_data--route--workflow-hook), never a direct write.
</Tip>

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

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

<Note>
  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` maps to `/reviews/:id`. See [Extending panels](/rc/resources/customization/extending-panels#routing-conventions).
</Note>

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

For a real screen you assemble the same primitives the built-in pages use. They are all re-exported from `@mercurjs/dashboard-shared`, so you import from one place instead of Medusa internals.

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

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

### Layout and list table

Pick a layout: `SingleColumnPage` for lists and simple pages, `TwoColumnPage` for a detail with a sidebar. Mount a `DataTable` inside the standard section shell. Build columns with `createColumnHelper`, wire the table with `useDataTable`, use page size 20, and pass `keepPreviousData` for smooth pagination.

```tsx apps/vendor/src/routes/reviews/page.tsx theme={null}
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 and value pairs. This is the canonical way Medusa renders read-only entity data.

```tsx theme={null}
<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` and `TwoColumnPage.Sidebar`, each stacked with `gap-y-3`.

### Edit page (drawer)

Quick edits live in a routed `RouteDrawer` with `Form` (React Hook Form plus Zod). Gate the form until the entity has loaded, and use `useRouteModal().handleSuccess()` to close on save.

```tsx apps/vendor/src/routes/reviews/[id]/edit/page.tsx theme={null}
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>
  )
}
```

## Data only through the typed SDK

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

```ts src/hooks/api/reviews.tsx theme={null}
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()`, and `detail(id)` in mutations. Throw on `isError` so the route `ErrorBoundary` catches it. Show a `Skeleton` while loading.

## Checklist for panel work

* **UI primitives:** built only from `@medusajs/ui` and `@medusajs/icons`, Medusa UI tokens only, no custom CSS.
* **Extending a screen:** a `defineCustomFieldsConfig` file (`@mercurjs/dashboard-sdk`) with `createFormHelper` (`@mercurjs/dashboard-shared`). Forms submit under `additional_data`.
* **Read-only state:** status and flags surfaced via `displays`. Changes go through the typed SDK and a workflow hook, not a direct write.
* **New screen:** a `page.tsx` under `src/routes/` with a typed `RouteConfig`. Compose it from `SingleColumnPage` or `TwoColumnPage`, `DataTable`, `SectionRow`, and `RouteDrawer`, all imported from `@mercurjs/dashboard-shared`, never Medusa-internal paths.
* **Data:** via `sdk.admin.*` or `sdk.vendor.*` in TanStack Query hooks. No raw `fetch`. Mutations invalidate the right keys.
* **Strings and test ids:** every visible string translated, every interactive element has a `data-testid`.

## Next steps

<CardGroup cols={2}>
  <Card title="Panel extensions reference" href="/references/panel-extensions/overview">
    The full reference for custom fields, widgets, and new pages.
  </Card>

  <Card title="Custom fields" href="/rc/resources/best-practices/custom-fields">
    The full backend and frontend loop, including the workflow hook.
  </Card>
</CardGroup>
