Skip to main content
Widgets are reusable UI components that can be injected into specific zones throughout the admin dashboard, allowing you to extend existing pages without modifying core code.

Overview

Widgets enable you to:
  • Add custom content to existing pages
  • Display additional information alongside core features
  • Integrate third-party services into the admin
  • Create reusable UI components across multiple pages

Creating a Widget

Basic Widget

Create a widget by defining a React component and specifying where it should appear:
1

Create Widget File

src/admin/widgets/order-tracking/index.tsx
2

Define the Widget Component

// src/admin/widgets/order-tracking/index.tsx
import { Container, Heading, Text } from "@medusajs/ui"

const OrderTrackingWidget = () => {
  return (
    <Container className="divide-y p-0">
      <div className="flex items-center justify-between px-6 py-4">
        <Heading level="h2">Order Tracking</Heading>
      </div>
      <div className="px-6 py-4">
        <Text>Custom tracking information will appear here</Text>
      </div>
    </Container>
  )
}

export default OrderTrackingWidget
3

Configure Injection Zone

import { defineWidgetConfig } from "@medusajs/admin-sdk"

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

export default OrderTrackingWidget
The widget will automatically appear on all order detail pages, after the main content.

Widget Configuration

The defineWidgetConfig function accepts:
interface WidgetConfig {
  /**
   * The injection zone(s) where the widget should appear
   */
  zone: InjectionZone | InjectionZone[]
}

Multiple Zones

Inject a widget into multiple locations:
import { defineWidgetConfig } from "@medusajs/admin-sdk"

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

Injection Zones

Injection zones are specific locations in the admin where widgets can be inserted. They follow the pattern:
{entity}.{page}.{position}

Available Zones

The full list of injection zones is defined in packages/admin/admin-shared/src/extensions/widgets/constants.ts:210:

Order Zones

"order.details.before"
"order.details.after"
"order.details.side.before"
"order.details.side.after"
"order.list.before"
"order.list.after"

Product Zones

"product.details.before"
"product.details.after"
"product.details.side.before"
"product.details.side.after"
"product.list.before"
"product.list.after"

Customer Zones

"customer.details.before"
"customer.details.after"
"customer.details.side.before"
"customer.details.side.after"
"customer.list.before"
"customer.list.after"

Other Entity Zones

// Product variants
"product_variant.details.before"
"product_variant.details.after"
"product_variant.details.side.before"
"product_variant.details.side.after"

// Collections
"product_collection.details.before"
"product_collection.details.after"
"product_collection.list.before"
"product_collection.list.after"

// Categories
"product_category.details.before"
"product_category.details.after"
"product_category.list.before"
"product_category.list.after"

// Promotions
"promotion.details.before"
"promotion.details.after"
"promotion.list.before"
"promotion.list.after"

// Price Lists
"price_list.details.before"
"price_list.details.after"
"price_list.list.before"
"price_list.list.after"

// And many more...
See the complete list in the source code.

Data Access

Route Parameters

Access route parameters to fetch relevant data:
import { useParams } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { Container, Heading } from "@medusajs/ui"

const ProductAnalyticsWidget = () => {
  const { id } = useParams()
  
  const { data: analytics } = useQuery({
    queryKey: ["product-analytics", id],
    queryFn: async () => {
      const response = await fetch(`/admin/analytics/products/${id}`)
      return response.json()
    }
  })
  
  return (
    <Container>
      <Heading level="h3">Product Analytics</Heading>
      <div className="mt-4">
        <p>Views: {analytics?.views || 0}</p>
        <p>Conversion: {analytics?.conversion || 0}%</p>
      </div>
    </Container>
  )
}

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

export default ProductAnalyticsWidget

Medusa SDK

Fetch data from Medusa using the Admin SDK:
import { useMedusa } from "@medusajs/dashboard"
import { useQuery } from "@tanstack/react-query"
import { Container, Badge } from "@medusajs/ui"

const OrderStatusWidget = () => {
  const { client } = useMedusa()
  
  const { data: orders } = useQuery({
    queryKey: ["recent-orders"],
    queryFn: async () => {
      const response = await client.orders.list({
        limit: 5,
        order: "-created_at"
      })
      return response.orders
    }
  })
  
  return (
    <Container>
      <h3 className="font-semibold mb-4">Recent Orders</h3>
      <div className="space-y-2">
        {orders?.map((order) => (
          <div key={order.id} className="flex justify-between items-center">
            <span className="text-sm">{order.display_id}</span>
            <Badge color={order.status === "completed" ? "green" : "orange"}>
              {order.status}
            </Badge>
          </div>
        ))}
      </div>
    </Container>
  )
}

export const config = defineWidgetConfig({
  zone: "order.list.before"
})

export default OrderStatusWidget

Styling Widgets

Using Design System

Leverage Medusa UI components for consistent styling:
import { 
  Container, 
  Heading, 
  Text, 
  Button,
  Badge,
  Table 
} from "@medusajs/ui"

const StyledWidget = () => {
  return (
    <Container className="divide-y p-0">
      <div className="flex items-center justify-between px-6 py-4">
        <div>
          <Heading level="h2">Widget Title</Heading>
          <Text size="small" className="text-ui-fg-subtle">
            Widget description
          </Text>
        </div>
        <Button variant="secondary" size="small">
          Action
        </Button>
      </div>
      <div className="px-6 py-4">
        <Badge color="green">Active</Badge>
      </div>
    </Container>
  )
}

Responsive Layout

const ResponsiveWidget = () => {
  return (
    <Container>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        <div className="p-4 border rounded-lg">
          <h4 className="font-semibold">Metric 1</h4>
          <p className="text-2xl mt-2">1,234</p>
        </div>
        <div className="p-4 border rounded-lg">
          <h4 className="font-semibold">Metric 2</h4>
          <p className="text-2xl mt-2">5,678</p>
        </div>
        <div className="p-4 border rounded-lg">
          <h4 className="font-semibold">Metric 3</h4>
          <p className="text-2xl mt-2">9,012</p>
        </div>
      </div>
    </Container>
  )
}

Advanced Patterns

Conditional Rendering

Show widgets based on conditions:
import { useParams } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"

const ConditionalWidget = () => {
  const { id } = useParams()
  
  const { data: product } = useQuery({
    queryKey: ["product", id],
    queryFn: async () => {
      const response = await fetch(`/admin/products/${id}`)
      return response.json()
    }
  })
  
  // Only show for digital products
  if (!product?.metadata?.is_digital) {
    return null
  }
  
  return (
    <Container>
      <h3>Digital Product Settings</h3>
      {/* Digital product specific content */}
    </Container>
  )
}

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

export default ConditionalWidget

Interactive Widgets

Create widgets with state and interactions:
import { useState } from "react"
import { Container, Button, Input } from "@medusajs/ui"
import { useMutation, useQueryClient } from "@tanstack/react-query"

const InteractiveWidget = () => {
  const [note, setNote] = useState("")
  const queryClient = useQueryClient()
  
  const { mutate: saveNote } = useMutation({
    mutationFn: async (text: string) => {
      await fetch("/admin/notes", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ note: text })
      })
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["notes"] })
      setNote("")
    }
  })
  
  return (
    <Container>
      <h3 className="font-semibold mb-4">Quick Notes</h3>
      <Input 
        value={note}
        onChange={(e) => setNote(e.target.value)}
        placeholder="Add a note..."
      />
      <Button 
        className="mt-2" 
        onClick={() => saveNote(note)}
        disabled={!note}
      >
        Save Note
      </Button>
    </Container>
  )
}

export const config = defineWidgetConfig({
  zone: "order.details.side.after"
})

export default InteractiveWidget

Chart Widget

Integrate charts and visualizations:
import { Container, Heading } from "@medusajs/ui"
import { useQuery } from "@tanstack/react-query"
import { LineChart, Line, XAxis, YAxis, Tooltip } from "recharts"

const SalesChartWidget = () => {
  const { data: salesData } = useQuery({
    queryKey: ["sales-chart"],
    queryFn: async () => {
      const response = await fetch("/admin/analytics/sales")
      return response.json()
    }
  })
  
  return (
    <Container>
      <Heading level="h3">Sales Trend</Heading>
      <div className="mt-4">
        <LineChart width={600} height={300} data={salesData}>
          <XAxis dataKey="date" />
          <YAxis />
          <Tooltip />
          <Line type="monotone" dataKey="sales" stroke="#8884d8" />
        </LineChart>
      </div>
    </Container>
  )
}

export const config = defineWidgetConfig({
  zone: "order.list.after"
})

export default SalesChartWidget

Complete Example

Here’s a complete widget that displays product inventory across locations:
// src/admin/widgets/product-inventory/index.tsx
import { defineWidgetConfig } from "@medusajs/admin-sdk"
import { useParams } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { Container, Heading, Table, Badge } from "@medusajs/ui"

type InventoryLevel = {
  location_id: string
  location_name: string
  stocked_quantity: number
  reserved_quantity: number
  available_quantity: number
}

const ProductInventoryWidget = () => {
  const { id } = useParams()
  
  const { data: inventory, isLoading } = useQuery({
    queryKey: ["product-inventory", id],
    queryFn: async () => {
      const response = await fetch(`/admin/products/${id}/inventory-levels`)
      return response.json() as Promise<InventoryLevel[]>
    }
  })
  
  if (isLoading) {
    return (
      <Container>
        <p>Loading inventory...</p>
      </Container>
    )
  }
  
  return (
    <Container className="divide-y p-0">
      <div className="flex items-center justify-between px-6 py-4">
        <Heading level="h2">Inventory by Location</Heading>
      </div>
      <div className="px-6 py-4">
        <Table>
          <Table.Header>
            <Table.Row>
              <Table.HeaderCell>Location</Table.HeaderCell>
              <Table.HeaderCell>In Stock</Table.HeaderCell>
              <Table.HeaderCell>Reserved</Table.HeaderCell>
              <Table.HeaderCell>Available</Table.HeaderCell>
              <Table.HeaderCell>Status</Table.HeaderCell>
            </Table.Row>
          </Table.Header>
          <Table.Body>
            {inventory?.map((level) => (
              <Table.Row key={level.location_id}>
                <Table.Cell>{level.location_name}</Table.Cell>
                <Table.Cell>{level.stocked_quantity}</Table.Cell>
                <Table.Cell>{level.reserved_quantity}</Table.Cell>
                <Table.Cell>{level.available_quantity}</Table.Cell>
                <Table.Cell>
                  <Badge color={level.available_quantity > 0 ? "green" : "red"}>
                    {level.available_quantity > 0 ? "In Stock" : "Out of Stock"}
                  </Badge>
                </Table.Cell>
              </Table.Row>
            ))}
          </Table.Body>
        </Table>
      </div>
    </Container>
  )
}

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

export default ProductInventoryWidget

Best Practices

Each widget should have a single, clear purpose. Create multiple small widgets rather than one large complex widget.
Always show loading indicators while fetching data to prevent layout shifts.
Wrap widgets in <Container> from @medusajs/ui for consistent spacing and styling.
Use React Query for efficient data fetching and caching. Avoid expensive computations in render.
Choose injection zones carefully. Use .side zones for supplementary info and .before/.after for primary content.

Next Steps

Build docs developers (and LLMs) love