3 MIN READ

Customising the Medusa Admin: Widgets, Routes and UI Extensions

The dashboard is a React app you can extend. Injecting widgets into existing pages, adding new routes, and the conventions that keep extensions from looking bolted on.

BY ANANYA IYERUPDATED
Illustration for “Customising the Medusa Admin: Widgets, Routes and UI Extensions” — Admin

The admin dashboard is the part of Medusa your client actually touches. It ships as a React application built into the backend, and everything you add lives in src/admin/ — no separate build, no separate deployment.

The technical bar is low. The design bar is not, and that is where most extensions fall down: a widget that works but looks like it came from a different product trains people not to trust it.

Widgets

A widget injects a component into an existing page. Declare where with a zone:

src/admin/widgets/product-supplier.tsxtsx
import { defineWidgetConfig } from "@medusajs/admin-sdk"
import { Container, Heading, Text, Badge } from "@medusajs/ui"
import { useQuery } from "@tanstack/react-query"
import type { DetailWidgetProps, AdminProduct } from "@medusajs/framework/types"

const ProductSupplierWidget = ({ data: product }: DetailWidgetProps<AdminProduct>) => {
  const { data, isLoading } = useQuery({
    queryKey: ["supplier", product.id],
    queryFn: async () => {
      const res = await fetch(`/admin/products/${product.id}/supplier`, {
        credentials: "include",
      })
      return res.json()
    },
  })

  return (
    <Container className="divide-y p-0">
      <div className="flex items-center justify-between px-6 py-4">
        <Heading level="h2">Supplier</Heading>
        {data?.supplier?.status && <Badge>{data.supplier.status}</Badge>}
      </div>
      <div className="px-6 py-4">
        {isLoading ? (
          <Text className="text-ui-fg-subtle">Loading…</Text>
        ) : (
          <Text>{data?.supplier?.name ?? "No supplier assigned"}</Text>
        )}
      </div>
    </Container>
  )
}

export const config = defineWidgetConfig({
  zone: "product.details.after",
})

export default ProductSupplierWidget

Zones follow the pattern <entity>.<page>.<position> — product.details.before, order.details.after, customer.details.side.after and so on. Detail-page widgets receive the record as data, so you rarely need to fetch the entity itself, only your related data.

Custom routes

A new page is a file under src/admin/routes/, where the directory path becomes the URL:

src/admin/routes/suppliers/page.tsxtsx
import { defineRouteConfig } from "@medusajs/admin-sdk"
import { Buildings } from "@medusajs/icons"
import { Container, Heading, Table, Button } from "@medusajs/ui"
import { useQuery } from "@tanstack/react-query"

const SuppliersPage = () => {
  const { data } = useQuery({
    queryKey: ["suppliers"],
    queryFn: async () => (await fetch("/admin/suppliers", { credentials: "include" })).json(),
  })

  return (
    <Container className="divide-y p-0">
      <div className="flex items-center justify-between px-6 py-4">
        <Heading level="h1">Suppliers</Heading>
        <Button size="small" variant="secondary">Add supplier</Button>
      </div>
      <Table>
        <Table.Header>
          <Table.Row>
            <Table.HeaderCell>Name</Table.HeaderCell>
            <Table.HeaderCell>Lead time</Table.HeaderCell>
          </Table.Row>
        </Table.Header>
        <Table.Body>
          {(data?.suppliers ?? []).map((s: any) => (
            <Table.Row key={s.id}>
              <Table.Cell>{s.name}</Table.Cell>
              <Table.Cell>{s.lead_time_days} days</Table.Cell>
            </Table.Row>
          ))}
        </Table.Body>
      </Table>
    </Container>
  )
}

export const config = defineRouteConfig({
  label: "Suppliers",
  icon: Buildings,
})

export default SuppliersPage

The config export puts it in the sidebar. Omit it and the page exists but is not linked — useful for detail pages reached from a list.

Nested routes work as you would expect: src/admin/routes/suppliers/[id]/page.tsx becomes /app/suppliers/:id.

Talk to your own API routes

Admin extensions run in the browser. They cannot resolve module services — those live on the server. So every extension pairs with an API route:

src/api/admin/products/[id]/supplier/route.tsts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"

export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const query = req.scope.resolve("query")

  const { data: [product] } = await query.graph({
    entity: "product",
    fields: ["id", "supplier.*"],
    filters: { id: req.params.id },
  })

  res.json({ supplier: product?.supplier ?? null })
}

Routes under /admin are authenticated by the dashboard session, so credentials: "include" on the fetch is all the client side needs.

Use the design system

@medusajs/ui ships the same components the dashboard is built from — Container, Heading, Table, Button, Badge, Input, Select, Toaster, and a token set exposed as Tailwind classes such as text-ui-fg-subtle and bg-ui-bg-base.

Use them. Three specific habits:

  • Never hardcode colours. Use the ui- tokens so extensions follow the dashboard's light and dark themes.
  • Wrap page content in Container. It supplies the card treatment, radius and spacing every other page has.
  • Use Toaster for feedback. Your own alert pattern will be the only one in the product.

React Query is already present in the dashboard, so use it for data fetching rather than adding another client.

What not to build here

The dashboard is for operators. Two things belong elsewhere:

Reporting at scale. A widget running an aggregate over three years of orders will make the product page feel broken. Precompute into a reporting table, or send people to a real BI tool.

Bulk operations. Long-running work should trigger a workflow and report progress, not block on an HTTP request.

Shipping extensions in a plugin

Admin extensions travel inside plugins — same src/admin/ structure, same conventions. This is how an integration ships its own settings page rather than asking every consumer to build one.

Common widget zones

Zones follow <entity>.<page>.<position>. The ones we use most:

ZoneRenders
`product.details.before`Above the product form
`product.details.after`Below the product form
`product.details.side.after`Right column, under the sidebar
`product.list.before`Above the product table
`order.details.after`Below order details
`order.details.side.before`Top of the order sidebar
`customer.details.after`Below customer details
`login.after`Under the login form

Detail-page widgets receive the record as data, so you rarely fetch the entity again — only the related data your widget adds.

Forms and mutations

Widgets that write need the same care as any client form: optimistic where safe, honest where not.

tsx
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Button, Input, toast } from "@medusajs/ui"

const queryClient = useQueryClient()

const { mutate, isPending } = useMutation({
  mutationFn: async (value: string) =>
    fetch(`/admin/products/${product.id}/supplier`, {
      method: "POST",
      credentials: "include",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ supplier: value }),
    }).then((r) => {
      if (!r.ok) throw new Error("Could not save supplier")
      return r.json()
    }),
  onSuccess: () => {
    toast.success("Supplier updated")
    queryClient.invalidateQueries({ queryKey: ["supplier", product.id] })
  },
  onError: (error) => toast.error(error.message),
})

Use the dashboard's own toast rather than your own notification pattern, and always invalidate the query you just changed — a widget that shows stale data after a save reads as broken.

Permissions

Widgets and routes render for anyone who can reach the dashboard. If an extension exposes something not everyone should see — costs, margins, payout details — enforce it on the server, not by hiding the component.

src/api/admin/margins/route.tsts
export async function GET(req: MedusaRequest, res: MedusaResponse) {
  const user = req.auth_context?.actor_id
  if (!(await canViewMargins(req.scope, user))) {
    return res.status(403).json({ message: "Forbidden" })
  }
  // …
}

Then have the widget hide itself when the request returns 403. Client-side hiding is presentation; the API route is the control. Anyone can open devtools, and a widget that only hides is not a permission.


The admin is usually the difference between a client who loves the platform and one who tolerates it. We spend real time here.

Frequently asked questions

How do I add a custom page to the Medusa admin?

Create `src/admin/routes/<path>/page.tsx` with a default-exported React component and a `defineRouteConfig` export giving it a label and icon. The directory path becomes the URL under `/app`, and the config adds the sidebar entry.

What are admin widget zones in Medusa?

Named injection points on existing dashboard pages, following the pattern `<entity>.<page>.<position>` — for example `product.details.after` or `order.details.side.before`. A widget declares its zone in `defineWidgetConfig` and Medusa renders it there.

Can admin extensions access module services directly?

No. They run in the browser, while module services run on the server. Expose an API route under `/admin` that resolves the service, and have the extension fetch it with `credentials: "include"`.

How do I style admin extensions consistently?

Use `@medusajs/ui` components and its `ui-` prefixed Tailwind tokens rather than custom CSS. That gives you the dashboard's spacing, radii, typography and both themes for free, and keeps extensions from reading as third-party.

Do I need to rebuild the admin after changing an extension?

In development the dashboard hot-reloads. For production the admin is built as part of the backend build, so a deploy picks up extension changes automatically — there is no separate admin deployment.

Can I remove or replace built-in admin pages?

You cannot delete core pages, but you can add routes that supersede them in your team's workflow and rely on permissions to limit access. Wholesale replacement of the dashboard usually means building your own operations UI against the Admin API instead.

[ Keep reading ]