Subchapter 2.1
architecture/admin-integration.mdMarkdown18 KBView on GitHub
The Medusa Admin dashboard is a React application that connects to your backend API. Understanding how to extend it with widgets and UI routes is essential for building complete features.
┌─────────────────────────────────────────────────┐
│ Admin Dashboard (React + Vite) │
│ Running at: http://localhost:9000/app │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Widgets │ │ UI Routes │ │
│ │ (Inject) │ │ (New Pages) │ │
│ └───────┬──────┘ └───────┬──────┘ │
│ │ │ │
│ └────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ JS SDK │ │
│ └──────┬───────┘ │
└──────────────────┼─────────────────────────────┘
│ HTTP Requests
▼
┌─────────────────────────────────────────────────┐
│ Backend API (Node.js) │
│ Running at: http://localhost:9000 │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ API Routes │ │ Workflows │ │
│ └──────┬───────┘ └───────┬──────┘ │
│ │ │ │
│ └──────────┬─────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Modules │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────┘What: React components injected into existing admin pages at predefined zones
When to use:
Examples:
// Widget Example
import { defineWidgetConfig } from "@medusajs/admin-sdk"
const ProductBrandWidget = ({ data: product }) => {
return <Container>Brand: {product.brand?.name}</Container>
}
export const config = defineWidgetConfig({
zone: "product.details", // Where to inject
})
export default ProductBrandWidgetWhat: Completely new pages in the admin dashboard
When to use:
Examples:
// UI Route Example
import { defineRouteConfig } from "@medusajs/admin-sdk"
const BrandsPage = () => {
return (
<Container>
<Heading>Brands</Heading>
<DataTable data={brands} />
</Container>
)
}
export const config = defineRouteConfig({
label: "Brands",
icon: TagSolid,
})
export default BrandsPage| Aspect | Widgets | UI Routes |
|---|---|---|
| Purpose | Extend existing pages | Create new pages |
| Location | Injected into zones | New URLs |
| Navigation | No sidebar entry | Sidebar menu item |
| File path | src/admin/widgets/ | src/admin/routes/ |
| Configuration | defineWidgetConfig() | defineRouteConfig() |
| Props | Receive page entity | No special props |
Show information from linked entities:
// Product Brand Widget - Display Only
const ProductBrandWidget = ({ data: product }: DetailWidgetProps<AdminProduct>) => {
const { data: queryResult } = useQuery({
queryFn: () => sdk.admin.product.retrieve(product.id, {
fields: "+brand.*", // Include brand relation
}),
queryKey: ["product", product.id, "brand"],
})
const brand = (queryResult?.product as ProductWithBrand)?.brand
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">Brand</Heading>
</div>
<div className="px-6 py-4">
<Text>{brand?.name || "-"}</Text>
</div>
</Container>
)
}
export const config = defineWidgetConfig({
zone: "product.details",
})Key points:
DetailWidgetProps<T> for type safetydata propfields parameter to include relationsWidget with buttons and user actions:
// Product Brand Widget - With Actions
const ProductBrandWidget = ({ data: product }: DetailWidgetProps<AdminProduct>) => {
const [isEditing, setIsEditing] = useState(false)
const { data: queryResult } = useQuery({
queryFn: () => sdk.admin.product.retrieve(product.id, {
fields: "+brand.*",
}),
queryKey: ["product", product.id, "brand"],
})
const updateMutation = useMutation({
mutationFn: (brandId: string) => {
return sdk.client.fetch(`/admin/products/${product.id}/brand`, {
method: "POST",
body: JSON.stringify({ brand_id: brandId }),
})
},
onSuccess: () => {
queryClient.invalidateQueries(["product", product.id, "brand"])
setIsEditing(false)
},
})
if (isEditing) {
return (
<Container>
<BrandSelector
onSelect={(brandId) => updateMutation.mutate(brandId)}
onCancel={() => setIsEditing(false)}
/>
</Container>
)
}
return (
<Container>
<div className="flex items-center justify-between">
<Heading level="h2">Brand</Heading>
<Button onClick={() => setIsEditing(true)}>Edit</Button>
</div>
<Text>{brand?.name || "-"}</Text>
</Container>
)
}Key points:
useMutation for updatesComplex forms in a modal:
// Product Brand Widget - With Modal
const ProductBrandWidget = ({ data: product }: DetailWidgetProps<AdminProduct>) => {
const [modalOpen, setModalOpen] = useState(false)
const { data: queryResult } = useQuery({
queryFn: () => sdk.admin.product.retrieve(product.id, {
fields: "+brand.*",
}),
queryKey: ["product", product.id, "brand"],
})
return (
<>
<Container>
<div className="flex items-center justify-between">
<Heading level="h2">Brand</Heading>
<Button onClick={() => setModalOpen(true)}>Change Brand</Button>
</div>
<Text>{brand?.name || "-"}</Text>
</Container>
{modalOpen && (
<ChangeBrandModal
product={product}
currentBrand={brand}
onClose={() => setModalOpen(false)}
/>
)}
</>
)
}Most common pattern for management pages:
// Brands List Page
import { defineRouteConfig } from "@medusajs/admin-sdk"
import { TagSolid } from "@medusajs/icons"
import { Container, Heading, DataTable, useDataTable } from "@medusajs/ui"
import { useQuery } from "@tanstack/react-query"
import { sdk } from "../../lib/sdk"
const BrandsPage = () => {
const [pagination, setPagination] = useState({
pageSize: 15,
pageIndex: 0,
})
const { data, isLoading } = useQuery({
queryFn: () => sdk.client.fetch(`/admin/brands`, {
query: {
limit: pagination.pageSize,
offset: pagination.pageIndex * pagination.pageSize,
},
}),
queryKey: ["brands", pagination.pageSize, pagination.pageIndex],
})
const table = useDataTable({
columns: [
{ accessor: "id", header: "ID" },
{ accessor: "name", header: "Name" },
{ accessor: "products", header: "Products", cell: (props) => props.getValue()?.length || 0 },
],
data: data?.brands || [],
rowCount: data?.count || 0,
isLoading,
pagination: {
state: pagination,
onPaginationChange: setPagination,
},
})
return (
<Container>
<DataTable instance={table}>
<DataTable.Toolbar>
<Heading>Brands</Heading>
</DataTable.Toolbar>
<DataTable.Table />
<DataTable.Pagination />
</DataTable>
</Container>
)
}
export const config = defineRouteConfig({
label: "Brands",
icon: TagSolid,
})
export default BrandsPageKey points:
useDataTable hook for table managementsdk.client.fetch() for custom API endpointsFor viewing/editing individual records:
// Brand Detail Page - src/admin/routes/brands/[id]/page.tsx
import { useParams } from "react-router-dom"
const BrandDetailPage = () => {
const { id } = useParams()
const { data: brand, isLoading } = useQuery({
queryFn: () => sdk.client.fetch(`/admin/brands/${id}`),
queryKey: ["brand", id],
})
if (isLoading) return <Loading />
return (
<Container>
<Heading>{brand.name}</Heading>
<Section title="Details">
<LabeledInput label="Name" value={brand.name} />
<LabeledInput label="Created" value={brand.created_at} />
</Section>
<Section title="Products">
<ProductsList products={brand.products} />
</Section>
</Container>
)
}
export default BrandDetailPageFile structure for nested routes:
src/admin/routes/brands/
├── page.tsx → /app/brands (list)
└── [id]/
└── page.tsx → /app/brands/:id (detail)For creating or editing records:
// Create Brand Page - src/admin/routes/brands/create/page.tsx
const CreateBrandPage = () => {
const navigate = useNavigate()
const createMutation = useMutation({
mutationFn: (data: { name: string }) => {
return sdk.client.fetch(`/admin/brands`, {
method: "POST",
body: JSON.stringify(data),
})
},
onSuccess: (result) => {
toast.success("Brand created successfully")
navigate(`/brands/${result.brand.id}`)
},
onError: (error) => {
toast.error(`Failed to create brand: ${error.message}`)
},
})
return (
<Container>
<Heading>Create Brand</Heading>
<Form onSubmit={(data) => createMutation.mutate(data)}>
<Input name="name" label="Name" required />
<Button type="submit" isLoading={createMutation.isLoading}>
Create
</Button>
</Form>
</Container>
)
}
export default CreateBrandPageProblem: Widget uses one query, modal uses a different query
Solution: Separate query keys
// In widget - lightweight query for display
const { data: product } = useQuery({
queryFn: () => sdk.admin.product.retrieve(productId, {
fields: "id,title,brand.name", // Only what we need
}),
queryKey: ["product", productId, "widget"], // Different key
})
// In modal - full query for editing
const { data: fullProduct } = useQuery({
queryFn: () => sdk.admin.product.retrieve(productId, {
fields: "*,brand.*,variants.*", // Everything
}),
queryKey: ["product", productId, "modal"], // Different key
enabled: modalOpen, // Only fetch when modal opens
})Update UI immediately, revert on error:
const updateMutation = useMutation({
mutationFn: (updates) => sdk.client.fetch(`/admin/brands/${brandId}`, {
method: "POST",
body: JSON.stringify(updates),
}),
onMutate: async (updates) => {
// Cancel outgoing queries
await queryClient.cancelQueries(["brand", brandId])
// Snapshot previous value
const previous = queryClient.getQueryData(["brand", brandId])
// Optimistically update
queryClient.setQueryData(["brand", brandId], (old) => ({
...old,
...updates,
}))
return { previous }
},
onError: (err, updates, context) => {
// Revert on error
queryClient.setQueryData(["brand", brandId], context.previous)
},
onSettled: () => {
// Refetch to sync
queryClient.invalidateQueries(["brand", brandId])
},
})Refresh queries after data changes:
const createBrandMutation = useMutation({
mutationFn: (data) => sdk.client.fetch(`/admin/brands`, {
method: "POST",
body: JSON.stringify(data),
}),
onSuccess: () => {
// Invalidate brands list to refetch
queryClient.invalidateQueries(["brands"])
// Also invalidate if product pages show brand
queryClient.invalidateQueries(["products"])
},
})For custom API endpoints, use sdk.client.fetch():
// Standard Medusa entities - use built-in methods
const product = await sdk.admin.product.retrieve(id)
const products = await sdk.admin.product.list()
// Custom entities - use client.fetch()
const brand = await sdk.client.fetch(`/admin/brands/${id}`)
const brands = await sdk.client.fetch(`/admin/brands`)
// Custom actions - use client.fetch() with method
const result = await sdk.client.fetch(`/admin/brands/${id}/approve`, {
method: "POST",
body: JSON.stringify({ approved: true }),
})Initialize once in src/admin/lib/sdk.ts:
import Medusa from "@medusajs/js-sdk"
export const sdk = new Medusa({
baseUrl: import.meta.env.VITE_BACKEND_URL || "/",
debug: import.meta.env.DEV,
auth: {
type: "session", // Important for admin!
},
})Key points:
import.meta.env (Vite environment variables)Always use Medusa UI components for consistent styling:
import {
Container,
Heading,
Text,
Button,
Input,
DataTable,
useDataTable,
createDataTableColumnHelper,
} from "@medusajs/ui"
import { TagSolid, PlusSolid } from "@medusajs/icons"Common components:
Common widget zones:
Product Pages:
product.detailsproduct.details.sideproduct.listOrder Pages:
order.detailsorder.details.sideorder.listCustomer Pages:
customer.detailscustomer.details.sidecustomer.listDeprecated suffixes: .before and .after still resolve, but since Medusa v2.17.2 they no longer control where the widget lands — admin users arrange widgets in the dashboard’s Editor view (Layout Composer). Prefer the unsuffixed zone name. Ask the MedusaDocs MCP server for the full, current zone list instead of guessing.
Use consistent, hierarchical naming:
// Good - hierarchical, specific
["product", productId, "brand"]
["brands", limit, offset]
["brand", brandId, "products"]
// Bad - flat, ambiguous
["productBrand"]
["getBrands"]Always handle loading and error states:
const { data, isLoading, error } = useQuery({ ... })
if (isLoading) return <Spinner />
if (error) return <ErrorMessage error={error} />
return <Content data={data} />Type your queries and mutations:
type Brand = {
id: string
name: string
products?: Product[]
}
const { data } = useQuery<{ brands: Brand[] }>({
queryFn: () => sdk.client.fetch(`/admin/brands`),
queryKey: ["brands"],
})
// Now data.brands is typed correctlyDon’t reuse the same query for different use cases:
// Display query - lightweight
const displayQuery = useQuery({
queryKey: ["entity", id, "display"],
queryFn: () => fetch(`/api/entity/${id}?fields=id,name`),
})
// Modal query - comprehensive
const modalQuery = useQuery({
queryKey: ["entity", id, "modal"],
queryFn: () => fetch(`/api/entity/${id}?fields=*`),
enabled: modalOpen,
})Admin dashboard integration extends Medusa’s UI:
Widgets:
UI Routes:
Key Technologies:
Remember: Admin is a separate React app that communicates with backend via HTTP. Use SDK for API calls, React Query for state management, and Medusa UI for consistent design.