Enforce correct page and section UI patterns when creating or modifying admin pages. Use when building list pages, detail pages, Container sections, action menus, empty states, delete flows, or any page-level UI in packages/admin.
Use this skill when:
Not for: form fields inside pages (use admin-form-ui), tabbed wizard forms (use admin-tab-ui).
Before building new custom UI, first apply medusa-ui-conformance.
Read next (as needed):
references/list-page-patterns.md — list page compound component structurereferences/detail-page-patterns.md — detail page sections, Container cards, SectionRowt("...") from .useTranslation()window.confirm or window.alert — use usePrompt() for delete confirmations.Layout wrapper — inline TwoColumnPage directly in Root.header={<Custom />}) — use nested compound components with Children.count.Heading level="h1" in sections — h1 is for page title only, h2 for sections.data-testid on key elements.ActionMenu component.NoRecords / NoResults components.isError + throw error.SingleColumnPage
└─ Table (compound)
├─ Header
│ ├─ Title (Heading)
│ └─ Actions
│ └─ CreateButton (Link + Button)
└─ DataTable (_DataTable with filters, search, pagination)
TwoColumnPage
├─ Main (first child)
│ ├─ MainGeneralSection (Container card)
│ ├─ MainMediaSection
│ └─ MainVariantSection
└─ Sidebar (second child)
├─ SidebarOrganizationSection
└─ SidebarSellerSection
<Container className="divide-y p-0">
{/* Header row */}
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("scope.section.title")}</Heading>
<div className="flex items-center gap-x-2">
<StatusBadge color={statusColor}>
{t(`scope.status.${status}`)}
</StatusBadge>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
icon: <PencilSquare />,
to: "edit",
},
],
},
{
actions: [
{
label: t("actions.delete"),
icon: <Trash />,
onClick: handleDelete,
},
],
},
]}
/>
</div>
</div>
{/* Data rows */}
<SectionRow title={t("fields.name")} value={data.name || "-"} />
<SectionRow title={t("fields.email")} value={data.email || "-"} />
<SectionRow title={t("fields.phone")} value={data.phone || "-"} />
</Container>
import { ActionMenu } from "@components/common/action-menu"
import { PencilSquare, Trash } from "@medusajs/icons"
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `/resource/${id}/edit`, // navigation
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete, // callback
},
],
},
]}
/>
Action type: either to (navigation link) or onClick (callback), never both.
Groups: separate edit actions from destructive actions with different groups.
import { usePrompt } from "@medusajs/ui"
const prompt = usePrompt()
const handleDelete = async () => {
const res = await prompt({
title: t("scope.delete.title"),
description: t("scope.delete.description", { name: item.name }),
verificationInstruction: t("general.typeToConfirm"),
verificationText: item.name,
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) return
await mutateAsync(undefined, {
onSuccess: () => {
toast.success(t("scope.delete.successToast"))
navigate("/resource-list", { replace: true })
},
})
}
<_DataTable
table={table}
columns={columns}
noRecords={{
message: t("scope.list.noRecordsMessage"),
}}
/>
import { NoRecords } from "@components/common/empty-table-content"
{items.length === 0 && (
<NoRecords
className="flex h-full flex-col overflow-hidden border-t p-6"
icon={null}
title={t("general.noRecordsTitle")}
message={t("general.noRecordsMessage")}
/>
)}
import { StatusBadge } from "@medusajs/ui"
// Color helper function
const productStatusColor = (status: string): "green" | "orange" | "red" | "grey" => {
switch (status) {
case "published": return "green"
case "draft": return "orange"
case "rejected": return "red"
default: return "grey"
}
}
<StatusBadge color={productStatusColor(product.status)}>
{t(`products.productStatus.${product.status}`)}
</StatusBadge>
| Element | Component | Level | Where |
|---|---|---|---|
| Page title | <Heading> | (default) | Page root / modal body |
| Section title | <Heading level="h2"> | h2 | Inside Container header |
| Drawer title | <Heading> | (default) | RouteDrawer.Header |
| Description | <Text size="small"> | — | Below heading |
| Subtle text | <Text size="small" className="text-ui-fg-subtle"> | — | Hints, descriptions |
const Root = () => {
const { id } = useParams()
const { data, isPending, isError, error } = useQuery(id!)
if (isError) {
throw error
}
const ready = !isPending && !!data
return ready ? (
<TwoColumnPage hasOutlet showJSON showMetadata data={data}>
<TwoColumnPage.Main>
<MainGeneralSection data={data} />
</TwoColumnPage.Main>
<TwoColumnPage.Sidebar>
<SidebarInfoSection data={data} />
</TwoColumnPage.Sidebar>
</TwoColumnPage>
) : (
<TwoColumnPageSkeleton mainSections={2} sidebarSections={1} />
)
}
const Root = ({ children }: { children?: ReactNode }) => {
return (
<SingleColumnPage>
{Children.count(children) > 0 ? children : <ResourceListTable />}
</SingleColumnPage>
)
}
export const ResourceListPage = Object.assign(Root, {
Table: ResourceListTable,
Header: ResourceListHeader,
HeaderTitle: ResourceListTitle,
HeaderActions: ResourceListActions,
HeaderCreateButton: ResourceListCreateButton,
DataTable: ResourceListDataTable,
})
const Root = ({ children }: { children?: ReactNode }) => {
const { id } = useParams()
const { data, isPending, isError, error } = useQuery(id!)
if (isError) throw error
const ready = !isPending && !!data
if (!ready) return <TwoColumnPageSkeleton ... />
return (
<TwoColumnPage hasOutlet showJSON showMetadata data={data}>
{Children.count(children) > 0 ? children : (
<>
<TwoColumnPage.Main>
<MainGeneralSection data={data} />
</TwoColumnPage.Main>
<TwoColumnPage.Sidebar>
<SidebarInfoSection data={data} />
</TwoColumnPage.Sidebar>
</>
)}
</TwoColumnPage>
)
}
export const ResourceDetailPage = Object.assign(Root, {
MainGeneralSection,
SidebarInfoSection,
})