docs/developer/dashboard/recipes/sidebar-widget.mdx
The product, order, and customer detail pages each render a <Slot> in their right-hand sidebar — typically named <resource>.form_sidebar. Widgets rendered into these slots see the current record and can fetch their own data without disturbing the host page.
This recipe builds a "Loyalty status" card for the customer detail page: read-only, fetches the customer's points + tier, shows a spinner while loading, an empty state when there's no data, and hides itself for staff without the right permission.
// src/widgets/loyalty-status-card.tsx
import { useQuery } from '@tanstack/react-query'
import { adminClient, usePermissions, useStore } from '@spree/dashboard-core'
import {
Badge,
Card, CardContent, CardHeader, CardTitle,
Empty, EmptyDescription,
Skeleton,
} from '@spree/dashboard-ui'
import { Trophy } from 'lucide-react'
interface LoyaltyRecord {
customer_id: string
points: number
tier: 'bronze' | 'silver' | 'gold'
}
interface Props {
// The customer detail page's slot context — see the slots catalog.
customer: { id: string }
}
export function LoyaltyStatusCard({ customer }: Props) {
const { storeId } = useStore()
const { permissions } = usePermissions()
const canRead = permissions.can('read', 'MyApp::LoyaltyRecord')
const { data, isLoading } = useQuery({
queryKey: ['loyalty', storeId, customer.id],
queryFn: () =>
adminClient.request<{ data: LoyaltyRecord | null }>(
'GET',
`/customers/${customer.id}/loyalty`,
),
staleTime: 30_000,
enabled: canRead,
})
// Hide entirely for staff without the permission — same behavior as
// core's permission-gated cards.
if (!canRead) return null
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="size-4" />
Loyalty status
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-8 w-32" />
) : !data?.data ? (
<Empty>
<EmptyDescription>No loyalty record</EmptyDescription>
</Empty>
) : (
<div className="space-y-2">
<Badge variant={tierVariant(data.data.tier)}>{data.data.tier}</Badge>
<p className="text-sm text-muted-foreground">{data.data.points} points</p>
</div>
)}
</CardContent>
</Card>
)
}
function tierVariant(tier: LoyaltyRecord['tier']) {
return tier === 'gold' ? 'default' : 'secondary'
}
Worth noting:
storeId in the query key. The dashboard is multi-store; without this, switching stores leaves stale data on screen.staleTime: 30_000 keeps the data fresh enough for a sidebar widget without re-fetching on every focus event.<Skeleton>), empty (<Empty>), and loaded. Skipping the empty state ships a card that says "0 points" when really the customer has never been enrolled — different meaning, different UX.// src/plugins.ts (in your dashboard app)
import { defineDashboardPlugin } from '@spree/dashboard-core'
import { LoyaltyStatusCard } from './widgets/loyalty-status-card'
defineDashboardPlugin({
slots: {
'customer.form_sidebar': [{
id: 'loyalty-status',
component: LoyaltyStatusCard as never,
position: 50,
}],
},
})
The permission gate lives inside the component (usePermissions) rather than in an if predicate on the entry — the predicate only receives the slot context today, not ambient permissions (see the slots catalog).
position: 50 puts the card near the top of the sidebar (built-in cards typically use 100/200/300). Pick a number based on whether your widget is more or less important than the others — for a "status" card, near the top is right.
The pattern above fetches its own data. The alternative is to read it from the resource the host passes:
function MyCard({ customer }: { customer: Customer }) {
return <div>{customer.loyalty_points}</div> // host already loaded it
}
Choose based on whether the data is hot path for the rest of the page:
| Data | Approach |
|---|---|
Already on the host's serializer (e.g., customer.tags) | Read from the slot context — no extra HTTP |
| Conditional, expensive, or owned by your plugin | Fetch in the widget |
| Sometimes-needed (e.g., gated by feature flag) | Fetch in the widget |
A widget that always fetches is wasteful when the host already has the data. A widget that reads from the host but the host doesn't serialize is broken. Match the pattern to the source of truth.
The built-in detail pages render their sidebar only after the record has loaded, so the context record is always set when your widget mounts. If you register into a slot where that's not guaranteed (check the slots catalog entry), guard defensively:
if (!customer) return null // or a <Skeleton> to reserve layout space
useQuery — query key conventions@spree/dashboard README — the multi-store model and why storeId belongs in the key