
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 tool | Sanity Dashboard | |
|---|---|---|
| What it is | A plugin that adds a "Dashboard" tool inside one Studio | The organisation-wide hub at sanity.io that lists your Studios and custom apps |
| Package | @sanity/dashboard | None, it's part of the platform |
| Customised with | Widgets you configure in sanity.config.ts | Platform settings and apps built with the App SDK |
| Scope | One Studio and its workspaces | All 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:
| Package | Widget | What it shows |
|---|---|---|
sanity-plugin-dashboard-widget-document-list | documentListWidget() | A list of documents filtered by type, ordered by any field |
sanity-plugin-dashboard-widget-vercel | vercelWidget() | Recent Vercel deployments, with a button to trigger a deploy hook |
sanity-plugin-dashboard-widget-netlify | netlifyWidget() | 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-19or later, the default perspective ispublished, which hides drafts. Passingperspective: 'raw'returns draft documents too, so thedrafts.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
typesandlimitas 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:
useClientruns 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()fromsanityand returnnullfor non-developers, or configure separate workspaces. - Match the Studio's look: use
@sanity/uicomponents 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.


