---
title: Add a sidebar widget
sidebarTitle: Sidebar widget
description: Add an info card to a built-in detail page's sidebar — pulling its own data, gated by permissions, with loading and empty states.
---

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.

## The component

```tsx
// src/slots/loyalty-status-card.tsx
import { useQuery } from '@tanstack/react-query'
import {
  adminClient,
  Badge,
  Card,
  CardContent,
  CardHeader,
  CardTitle,
  Empty,
  EmptyDescription,
  Skeleton,
  usePermissions,
  useStore,
} from '@spree/dashboard'
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.
- **Three states.** Loading (`<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.
- **Strings are hardcoded for brevity.** Real widgets should go through i18n — see [Translations](../customization/translations.md).

## Register the widget

```ts
// src/plugins.ts (in your dashboard app)
import { defineDashboardPlugin } from '@spree/dashboard'
import { LoyaltyStatusCard } from './slots/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](../slots-catalog.md#ambient-context)).

`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.

## When to fetch in the widget vs. read from the host

The pattern above fetches its own data. The alternative is to read it from the resource the host passes:

```tsx
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.

## Loading states for the host page

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:

```tsx
if (!customer) return null  // or a <Skeleton> to reserve layout space
```

## Reference

- [Slots catalog → form sidebars](../slots-catalog.md) — every page-level sidebar slot
- [`useQuery`](https://tanstack.com/query/v5/docs/framework/react/reference/useQuery) — query key conventions
- [`@spree/dashboard` README](https://github.com/spree/spree/blob/main/packages/dashboard/README.md) — the multi-store model and why `storeId` belongs in the key
