Type something to search...
How to Build Custom Dashboard Widgets in Sanity Studio

How to Build Custom Dashboard Widgets in Sanity Studio

To build custom dashboard widgets in Sanity Studio, install the @sanity/dashboard plugin, add dashboardTool() to the plugins array in sanity.config.ts, and pass it a widgets array. Each widget is a plain object with a name, a React component and an optional layout, so a custom widget is simply a React component, usually wrapped in the plugin's DashboardWidgetContainer and using useClient to run GROQ queries. Wrap that object in a small factory function and you can configure and reuse the widget like any official one.

A dashboard gives editors a landing page with the information they actually need: drafts waiting for review, recently published posts, stale content, deployment status or links to style guides. It costs little to build because widgets are ordinary React. This guide covers the difference between the Studio Dashboard tool and the platform-level Sanity Dashboard, installing and configuring the plugin, ready-made widgets, building custom widgets with static and live data, adding configuration, layout, and practical tips for keeping a dashboard fast.

Two Things Called "Dashboard"

Sanity uses the word for two different products, and it's worth separating them before you start:

Studio Dashboard toolSanity Dashboard
What it isA plugin that adds a "Dashboard" tool inside one StudioThe organisation-wide hub at sanity.io that lists your Studios and custom apps
Package@sanity/dashboardNone, it's part of the platform
Customised withWidgets you configure in sanity.config.tsPlatform settings and apps built with the App SDK
ScopeOne Studio and its workspacesAll projects in an organisation

This article is about the first: the Studio tool and its widgets. If you're new to how tools sit in the Studio's navigation, the Studio walkthrough is a good primer.

Installing the Dashboard Tool

Install the plugin in your Studio project:

npm install @sanity/dashboard

Then add it to your config. The order of the plugins array decides which tool opens first, so put dashboardTool() before structureTool() if you want editors to land on the dashboard:

// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { dashboardTool } from '@sanity/dashboard'
import { schemaTypes } from './schemaTypes'

export default defineConfig({
  name: 'default',
  title: 'Company Website',
  projectId: 'your-project-id',
  dataset: 'production',
  plugins: [
    dashboardTool({ widgets: [] }),
    structureTool(),
  ],
  schema: { types: schemaTypes },
})

Run npm run dev and open http://localhost:3333/dashboard. With no widgets, you'll see an empty dashboard with a prompt to add some.

Built-In and Ready-Made Widgets

The plugin includes three widgets that need no extra install:

  • projectInfoWidget: project ID, dataset and links to the project's management pages and APIs.
  • projectUsersWidget: the members of the project and their roles.
  • sanityTutorialsWidget: a feed of Sanity guides and tutorials.
import {
  dashboardTool,
  projectInfoWidget,
  projectUsersWidget,
  sanityTutorialsWidget,
} from '@sanity/dashboard'

dashboardTool({
  widgets: [
    projectInfoWidget(),
    projectUsersWidget({ layout: { width: 'medium' } }),
    sanityTutorialsWidget(),
  ],
})

Community and official widgets are installed like any package. Three that are useful on most projects:

PackageWidgetWhat it shows
sanity-plugin-dashboard-widget-document-listdocumentListWidget()A list of documents filtered by type, ordered by any field
sanity-plugin-dashboard-widget-vercelvercelWidget()Recent Vercel deployments, with a button to trigger a deploy hook
sanity-plugin-dashboard-widget-netlifynetlifyWidget()Netlify deploy status and a button to trigger a build

You can add several instances of the same widget with different settings:

import { documentListWidget } from 'sanity-plugin-dashboard-widget-document-list'

dashboardTool({
  widgets: [
    documentListWidget({ title: 'Recently edited', order: '_updatedAt desc', limit: 8 }),
    documentListWidget({
      title: 'New posts',
      order: '_createdAt desc',
      types: ['post'],
      showCreateButton: true,
    }),
  ],
})

The deploy widgets pair well with webhook-triggered rebuilds, giving editors a manual "rebuild now" button alongside the automatic one.

The Shape of a Widget

A widget is an object that matches the plugin's DashboardWidget interface:

interface DashboardWidget {
  name: string
  component: React.ComponentType<any>
  layout?: {
    width?: 'auto' | 'small' | 'medium' | 'large' | 'full'
    height?: 'auto' | 'small' | 'medium' | 'large' | 'full'
  }
}

That's all the dashboard needs. It lays widgets out in a responsive grid in the order you list them, and renders each component. Everything else, such as data fetching, configuration and styling, is up to you.

Building Your First Custom Widget

Start with a static widget that links editors to your writing guidelines. DashboardWidgetContainer gives you the standard card with a header, body and optional footer, and @sanity/ui provides components that match the Studio's look:

// widgets/guidelines/GuidelinesWidget.tsx
import { DashboardWidgetContainer } from '@sanity/dashboard'
import { Button, Card, Stack, Text } from '@sanity/ui'

export function GuidelinesWidget() {
  return (
    <DashboardWidgetContainer
      header="Editorial guidelines"
      footer={
        <Button
          as="a"
          href="https://example.com/style-guide"
          target="_blank"
          rel="noopener noreferrer"
          mode="bleed"
          tone="primary"
          text="Open the style guide"
          style={{ width: '100%' }}
        />
      }
    >
      <Card padding={4}>
        <Stack space={3}>
          <Text size={1}>Write headlines in sentence case.</Text>
          <Text size={1}>Every image needs alt text.</Text>
          <Text size={1}>Ask a reviewer before publishing news posts.</Text>
        </Stack>
      </Card>
    </DashboardWidgetContainer>
  )
}

Then export a factory function that returns the widget object:

// widgets/guidelines/index.ts
import type { DashboardWidget, LayoutConfig } from '@sanity/dashboard'
import { GuidelinesWidget } from './GuidelinesWidget'

export function guidelinesWidget(config: { layout?: LayoutConfig } = {}): DashboardWidget {
  return {
    name: 'guidelines-widget',
    component: GuidelinesWidget,
    layout: config.layout ?? { width: 'medium' },
  }
}

And register it:

dashboardTool({
  widgets: [guidelinesWidget(), projectInfoWidget()],
})

The factory pattern is the same one the official widgets use. It keeps sanity.config.ts tidy and makes the widget easy to move into a plugin later; see creating a custom Sanity plugin.

Fetching Content in a Widget

The most valuable widgets show content. Inside the Studio, useClient from sanity returns a client already authenticated as the current user, so queries respect their permissions and see drafts they're allowed to see.

Here's a widget that lists drafts that have never been published, a handy "waiting for review" queue:

// widgets/unpublished/UnpublishedWidget.tsx
import { useEffect, useState } from 'react'
import { useClient } from 'sanity'
import { IntentLink } from 'sanity/router'
import { DashboardWidgetContainer } from '@sanity/dashboard'
import { Card, Flex, Spinner, Stack, Text } from '@sanity/ui'

type Draft = { _id: string; _type: string; title?: string; _updatedAt: string }

const QUERY = `*[
  _id in path("drafts.**")
  && _type in $types
  && !defined(*[_id == string::split(^._id, "drafts.")[1]][0])
] | order(_updatedAt desc)[0...$limit]{ _id, _type, title, _updatedAt }`

export function UnpublishedWidget({ types, limit }: { types: string[]; limit: number }) {
  const client = useClient({ apiVersion: '2025-02-19' })
  const [drafts, setDrafts] = useState<Draft[] | null>(null)

  useEffect(() => {
    client
      .fetch<Draft[]>(QUERY, { types, limit }, { perspective: 'raw' })
      .then(setDrafts)
      .catch(() => setDrafts([]))
  }, [client, types, limit])

  return (
    <DashboardWidgetContainer header="Never published">
      <Card padding={3}>
        {drafts === null ? (
          <Flex justify="center" padding={4}>
            <Spinner muted />
          </Flex>
        ) : drafts.length === 0 ? (
          <Text size={1} muted>Nothing waiting. Nice work.</Text>
        ) : (
          <Stack space={3}>
            {drafts.map((doc) => (
              <IntentLink
                key={doc._id}
                intent="edit"
                params={{ id: doc._id.replace('drafts.', ''), type: doc._type }}
              >
                <Text size={1}>{doc.title || 'Untitled'}</Text>
              </IntentLink>
            ))}
          </Stack>
        )}
      </Card>
    </DashboardWidgetContainer>
  )
}

A few details matter here:

  • Perspective: with API version 2025-02-19 or later, the default perspective is published, which hides drafts. Passing perspective: 'raw' returns draft documents too, so the drafts. filter works.
  • IntentLink: navigates to the document in the Structure tool without hard-coding a URL, so it keeps working if your desk structure changes.
  • Parameters: pass types and limit as GROQ parameters rather than building the string by hand. The GROQ parameters post explains why.

Making a Widget Update Live

A fetch on mount is fine for most widgets, but a review queue is more useful when it updates as people publish. The useListeningQuery hook from sanity-plugin-utils runs a GROQ query and re-runs it when matching documents change:

npm install sanity-plugin-utils
// widgets/stats/ContentStatsWidget.tsx
import { useListeningQuery } from 'sanity-plugin-utils'
import { DashboardWidgetContainer } from '@sanity/dashboard'
import { Card, Grid, Stack, Text, Spinner } from '@sanity/ui'

type Stats = { published: number; drafts: number; missingSeo: number }

const QUERY = `{
  "published": count(*[_type == "post" && !(_id in path("drafts.**"))]),
  "drafts": count(*[_type == "post" && _id in path("drafts.**")]),
  "missingSeo": count(*[_type == "post" && !defined(seo.description)])
}`

export function ContentStatsWidget() {
  const { data, loading, error } = useListeningQuery<Stats>(QUERY, {
    initialValue: { published: 0, drafts: 0, missingSeo: 0 },
  })

  return (
    <DashboardWidgetContainer header="Posts at a glance">
      <Card padding={4}>
        {loading ? (
          <Spinner muted />
        ) : error ? (
          <Text size={1} muted>Couldn't load stats.</Text>
        ) : (
          <Grid columns={3} gap={3}>
            <Stat label="Published" value={data?.published} />
            <Stat label="Drafts" value={data?.drafts} />
            <Stat label="Missing SEO" value={data?.missingSeo} />
          </Grid>
        )}
      </Card>
    </DashboardWidgetContainer>
  )
}

function Stat({ label, value }: { label: string; value?: number }) {
  return (
    <Stack space={2}>
      <Text size={4} weight="semibold">{value ?? 0}</Text>
      <Text size={1} muted>{label}</Text>
    </Stack>
  )
}

Live queries keep a listener open per widget, and every Studio tab with the dashboard open holds those listeners. That's fine for a handful of widgets, but avoid putting a dozen live widgets with broad queries on one dashboard. Counting with count() on the server, as above, is far cheaper than fetching documents and counting in the browser. The GROQ functions post covers count() and defined() in more detail.

Adding Configuration Options

Accept options in the factory and pass them to the component with a small wrapper. This lets you reuse one widget for several document types:

// widgets/unpublished/index.tsx
import type { DashboardWidget, LayoutConfig } from '@sanity/dashboard'
import { UnpublishedWidget } from './UnpublishedWidget'

type Options = { types?: string[]; limit?: number; layout?: LayoutConfig }

export function unpublishedWidget(options: Options = {}): DashboardWidget {
  const types = options.types ?? ['post']
  const limit = options.limit ?? 10

  return {
    name: `unpublished-widget-${types.join('-')}`,
    component: () => <UnpublishedWidget types={types} limit={limit} />,
    layout: options.layout ?? { width: 'medium' },
  }
}

Define types once in the factory rather than inline in the component call. A new array on every render would change the useEffect dependency and trigger endless refetches.

Laying Out the Dashboard

Widgets fill a responsive grid in array order. Each widget's layout.width and layout.height accept auto, small, medium, large or full. A typical arrangement puts a full-width summary at the top and smaller lists below:

// sanity.config.ts
dashboardTool({
  widgets: [
    contentStatsWidget({ layout: { width: 'full' } }),
    unpublishedWidget({ types: ['post', 'page'], layout: { width: 'medium' } }),
    documentListWidget({ title: 'Recently edited', order: '_updatedAt desc', layout: { width: 'medium' } }),
    guidelinesWidget({ layout: { width: 'small' } }),
  ],
})

You can also rename the tool, give it an icon and add a second dashboard with a different name, which is useful if developers want a separate page of technical widgets:

import { ActivityIcon } from '@sanity/icons'

dashboardTool({
  name: 'stats',
  title: 'Statistics',
  icon: ActivityIcon,
  widgets: [contentStatsWidget()],
})

Practical Tips

  • Show what editors act on: queues ("needs review", "missing alt text", "not updated in a year") beat vanity numbers.
  • Respect permissions: useClient runs as the signed-in user, so a widget won't show documents a user can't read. Never embed a token in a widget.
  • Keep secrets out of widgets: anything in Studio code reaches the browser. Call your own endpoint for third-party APIs.
  • Hide technical widgets by role: read useCurrentUser() from sanity and return null for non-developers, or configure separate workspaces.
  • Match the Studio's look: use @sanity/ui components so widgets follow light and dark mode and any custom Studio theme.

FAQ: Sanity Dashboard Widgets

No. You install the @sanity/dashboard package and add dashboardTool() to the plugins array. Some starter templates include it, but a Studio created from a blank template doesn't.

Put dashboardTool() before structureTool() in the plugins array. The Studio opens the first tool in the list by default.

Yes. useClient returns a client authenticated as the current user. With a recent API version, set the perspective to raw (or drafts) in the fetch options so draft documents are included in the results.

The Studio dashboard is a plugin that adds a tool with widgets inside one Studio. Sanity Dashboard is the organisation-wide hub on sanity.io where you open your Studios and custom apps. They are configured separately.

Yes. The Vercel and Netlify dashboard widgets show recent deployments and include a button that calls a deploy or build hook, so editors can start a rebuild manually.

Only if they do a lot of work. Keep queries narrow, count on the server with count(), and limit the number of live, listening widgets, since each one keeps a connection open while the dashboard is visible.


Conclusion

A Sanity Studio dashboard is a list of widgets, and a widget is a name, a React component and a layout. Install @sanity/dashboard, start with the built-in and ready-made widgets, then build your own with DashboardWidgetContainer, @sanity/ui and useClient or useListeningQuery for live data. Factory functions make widgets configurable and reusable.

The best dashboards answer the question every editor has when they log in: what needs my attention? Build two or three widgets around that, such as a review queue and a list of content missing required fields, and you'll find editors open the dashboard every day instead of skipping straight past it.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper