Subchapter 2.15
lessons/lesson-3-admin-dashboard.mdMarkdown12 KBView on GitHub
By the end of this lesson, you will:
Time: 45-60 minutes
Prerequisites: Completed Lessons 1-2 (Brand Module, links, workflow hooks)
Now that brands exist in the backend, let’s build the admin UI:
By the end, admins will be able to:
Documentation: Admin Widgets (opens in a new tab) | Admin UI Routes (opens in a new tab)
The JS SDK simplifies sending requests to Medusa’s API routes.
Create 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",
},
})Configuration:
baseUrl: Medusa server URL (use environment variable or default to “/”)debug: Enable logging in developmentauth.type: “session” for admin dashboardImportant: Admin uses Vite, so environment variables are import.meta.env.*
Documentation: JS SDK Reference (opens in a new tab)
A widget is a React component injected into existing admin pages at predefined zones.
Common zones:
product.details - Main section of the product details pageproduct.details.side - Side column of the product details pageorder.details - Main section of the order details pageNote: Since Medusa v2.17.2, the .before and .after zone suffixes are deprecated. Widgets in a zone are ordered by the admin user in the dashboard’s Editor view (the Layout Composer), and the arrangement is saved. Use the unsuffixed zone name and let the user position the widget.
Create src/admin/widgets/product-brand.tsx:
import { defineWidgetConfig } from "@medusajs/admin-sdk"
import { DetailWidgetProps, AdminProduct } from "@medusajs/framework/types"
import { Container, Heading, Text } from "@medusajs/ui"
import { useQuery } from "@tanstack/react-query"
import { sdk } from "../lib/sdk"
type AdminProductBrand = AdminProduct & {
brand?: {
id: string
name: string
}
}
const ProductBrandWidget = ({
data: product,
}: DetailWidgetProps<AdminProduct>) => {
const { data: queryResult, isLoading } = useQuery({
queryFn: () => sdk.admin.product.retrieve(product.id, {
fields: "+brand.*",
}),
queryKey: ["product", product.id, "brand"],
})
const brandName = (queryResult?.product as AdminProductBrand)?.brand?.name
if (isLoading) {
return (
<Container className="divide-y p-0">
<div className="px-6 py-4">
<Text size="small">Loading brand...</Text>
</div>
</Container>
)
}
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="grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
Name
</Text>
<Text size="small" leading="compact">
{brandName || "-"}
</Text>
</div>
</Container>
)
}
export const config = defineWidgetConfig({
zone: "product.details",
})
export default ProductBrandWidgetKey concepts:
1. Widget Props:
DetailWidgetProps<AdminProduct>data prop2. Data Fetching:
useQuery({
queryFn: () => sdk.admin.product.retrieve(product.id, {
fields: "+brand.*",
}),
queryKey: ["product", product.id, "brand"],
})useQuery) for data fetchingfields parameter to get linked brand3. Medusa UI Components:
@medusajs/uiContainer, Heading, Text, Button4. Widget Configuration:
export const config = defineWidgetConfig({
zone: "product.details",
})configStart dev server:
npm run devOpen admin: http://localhost:9000/app (opens in a new tab)
Navigate to product: Go to Products → Select a product with a brand
Verify widget: See brand widget in the page’s main section (drag it where you want it in the Editor view)
Widget not showing:
config is exported“Cannot find module ‘@tanstack/react-query’” (pnpm users only):
pnpm list @tanstack/react-query --depth=10 | grep @medusajs/dashboard
pnpm add @tanstack/react-query@[exact-version]A UI Route is a new page in the admin dashboard.
File path determines URL:
src/admin/routes/brands/page.tsx → /app/brandssrc/admin/routes/settings/team/page.tsx → /app/settings/teamFirst, update the backend to support pagination.
Update src/api/admin/brands/route.ts:
// Add this after your existing POST handler
export const GET = async (
req: MedusaRequest,
res: MedusaResponse
) => {
const query = req.scope.resolve("query")
const {
data: brands,
metadata: { count, take, skip } = {},
} = await query.graph({
entity: "brand",
...req.queryConfig,
})
res.json({
brands,
count,
limit: take,
offset: skip,
})
}Then configure query middleware in src/api/middlewares.ts:
import {
defineMiddlewares,
validateAndTransformBody,
validateAndTransformQuery,
} from "@medusajs/framework/http"
import { createFindParams } from "@medusajs/medusa/api/utils/validators"
// ... other imports
export const GetBrandsSchema = createFindParams()
export default defineMiddlewares({
routes: [
// ... existing routes ...
{
matcher: "/admin/brands",
method: "GET",
middlewares: [
validateAndTransformQuery(
GetBrandsSchema,
{
defaults: ["id", "name", "products.*"],
isList: true,
}
),
],
},
],
})Documentation: Request Query Config Middleware (opens in a new tab)
Create src/admin/routes/brands/page.tsx:
import { defineRouteConfig } from "@medusajs/admin-sdk"
import { TagSolid } from "@medusajs/icons"
import {
Container,
Heading,
createDataTableColumnHelper,
DataTable,
useDataTable,
} from "@medusajs/ui"
import { useQuery } from "@tanstack/react-query"
import { sdk } from "../../lib/sdk"
import { useState, useMemo } from "react"
type Brand = {
id: string
name: string
products?: { id: string; title: string }[]
}
type BrandsResponse = {
brands: Brand[]
count: number
limit: number
offset: number
}
const columnHelper = createDataTableColumnHelper<Brand>()
const columns = [
columnHelper.accessor("id", {
header: "ID",
}),
columnHelper.accessor("name", {
header: "Name",
}),
columnHelper.accessor("products", {
header: "Products",
cell: ({ getValue }) => {
const products = getValue()
return products?.length || 0
},
}),
]
const BrandsPage = () => {
const limit = 15
const [pagination, setPagination] = useState({
pageSize: limit,
pageIndex: 0,
})
const offset = useMemo(() => {
return pagination.pageIndex * limit
}, [pagination])
const { data, isLoading } = useQuery<BrandsResponse>({
queryFn: () => sdk.client.fetch(`/admin/brands`, {
query: { limit, offset },
}),
queryKey: ["brands", limit, offset],
})
const table = useDataTable({
columns,
data: data?.brands || [],
getRowId: (row) => row.id,
rowCount: data?.count || 0,
isLoading,
pagination: {
state: pagination,
onPaginationChange: setPagination,
},
})
return (
<Container className="divide-y p-0">
<DataTable instance={table}>
<DataTable.Toolbar className="flex flex-col items-start justify-between gap-2 md:flex-row md:items-center">
<Heading>Brands</Heading>
</DataTable.Toolbar>
<DataTable.Table />
<DataTable.Pagination />
</DataTable>
</Container>
)
}
export const config = defineRouteConfig({
label: "Brands",
icon: TagSolid,
})
export default BrandsPageKey concepts:
1. Route Configuration:
export const config = defineRouteConfig({
label: "Brands",
icon: TagSolid,
})label: Display nameicon: From @medusajs/icons2. Data Table Setup:
const columns = [
columnHelper.accessor("id", { header: "ID" }),
columnHelper.accessor("name", { header: "Name" }),
]
const table = useDataTable({
columns,
data: data?.brands || [],
rowCount: data?.count || 0,
pagination: { state, onPaginationChange },
})3. Custom API Fetch:
sdk.client.fetch(`/admin/brands`, {
query: { limit, offset },
})sdk.client.fetch() for custom routesquery objectRoute not showing:
page.tsx (not route.tsx)config is exportedTable empty:
curl http://localhost:9000/admin/brandsFantastic! You’ve customized the Medusa Admin:
Admin Widgets:
UI Routes:
React Query:
useQuery for fetchingMedusa UI:
You’ve completed all 3 lessons and built a complete feature:
Backend:
Frontend:
git add .
git commit -m "Complete Lesson 3: Admin Dashboard customization"Deploy your feature:
npm run buildBuild more features:
Congratulations on completing the Medusa learning tutorial! You now understand the architecture and can build custom features confidently.