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:
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 ProductSupplierWidgetZones 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:
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 SuppliersPageThe 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:
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
Toasterfor 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:
| Zone | Renders |
|---|---|
| `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.
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.
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.
Medusa Plugins: Building and Publishing Reusable Commerce Packages
When a module should become a plugin, how the package is structured, and what changes when your code has to run in someone else's application.
Medusa Project Structure: Where Every Kind of Code Belongs
Medusa loads code by convention. A directory-by-directory guide to what goes where, and the decision tree for placing any new piece of logic.
Medusa Events and Subscribers: Reacting Without Coupling
How Medusa's event bus works, when a subscriber is the right tool instead of a workflow step, and the retry semantics you need to design around.



