
Using Document Badges and Previews in Sanity
Document badges in Sanity are small coloured labels shown in the document editor next to the action buttons, such as the default Draft and Published badges. You add your own by writing a badge component, a function that receives the document's draft and published versions and returns an object with a label, an optional title tooltip and a color of primary, success, warning or danger, or null to show nothing. Register it in document.badges in sanity.config.ts. Badges work best alongside previews: the list preview configured in each schema type, and preview panes that show the document as it will look on your site.
Status is the thing editors lose track of most often. Is this post approved? Is it scheduled? Is it missing an image? Badges answer that inside the document, list previews answer it while scanning a list, and a preview pane answers "what will this look like?" without leaving the editor. This guide covers how badges work, writing custom badges with TypeScript, showing them conditionally, pairing them with a status field and custom actions, reflecting the same status in list previews, and adding preview panes and production URLs.
Where Badges Appear
Open any document in the Structure tool and look at the footer. Next to the Publish button you'll see the built-in badges:
| Badge | When it appears |
|---|---|
| Published | The document has a published version |
| Draft | The document has unpublished changes |
| Live | The type uses liveEdit, so changes publish immediately |
A badge is display-only. It has no click behaviour and no menu. That makes it a safe place to surface information without inviting editors to change anything. If you want a status editors can act on, combine a badge with a custom document action.
Writing Your First Badge
A badge component receives the document's state and returns a description. Type it with DocumentBadgeComponent from sanity:
// badges/WordCountBadge.ts
import type { DocumentBadgeComponent } from 'sanity'
export const WordCountBadge: DocumentBadgeComponent = (props) => {
const doc = props.draft ?? props.published
const blocks = (doc?.body as { children?: { text?: string }[] }[] | undefined) ?? []
const words = blocks
.flatMap((block) => block.children ?? [])
.map((child) => child.text ?? '')
.join(' ')
.split(/\s+/)
.filter(Boolean).length
if (words === 0) return null
return {
label: `${words} words`,
title: 'Approximate word count of the body',
color: words < 300 ? 'warning' : 'primary',
}
}
The props mirror those of document actions:
- id: the published document ID.
- type: the schema type name.
- draft: the draft document, or
nullif there are no unpublished changes. - published: the published document, or
nullif it's never been published.
The description has four optional keys:
| Key | Purpose |
|---|---|
label | The text shown on the badge |
title | Tooltip text on hover |
color | 'primary', 'success', 'warning' or 'danger' |
icon | An optional icon |
Returning null hides the badge, which is how you make badges conditional on content.
Registering Badges
Badges are registered on the document key, with the same two forms as actions. A static array appends your badges to the defaults:
// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { schemaTypes } from './schemaTypes'
import { WordCountBadge } from './badges/WordCountBadge'
export default defineConfig({
name: 'default',
projectId: 'your-project-id',
dataset: 'production',
plugins: [structureTool()],
schema: { types: schemaTypes },
document: {
badges: [WordCountBadge],
},
})
A resolver function receives the current badges and a context object with schemaType, documentId, currentUser and more, and returns the full list:
document: {
badges: (prev, context) =>
context.schemaType === 'post' ? [...prev, WordCountBadge] : prev,
},
As with actions, the resolver must return prev (or a filtered copy of it) or the built-in Draft and Published badges disappear. Filtering them out deliberately is valid if your own badges replace them, but editors rely on them, so think twice.
A Workflow Status Badge
The most common use is showing a status stored in a field. Start with the field:
// schemaTypes/post.ts
import { defineField, defineType } from 'sanity'
export const STATUSES = [
{ title: 'Writing', value: 'writing' },
{ title: 'In review', value: 'review' },
{ title: 'Approved', value: 'approved' },
{ title: 'Needs changes', value: 'changes' },
] as const
export const post = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string', validation: (rule) => rule.required() }),
defineField({
name: 'status',
type: 'string',
options: { list: [...STATUSES], layout: 'radio', direction: 'horizontal' },
initialValue: 'writing',
}),
defineField({ name: 'mainImage', type: 'image', options: { hotspot: true } }),
defineField({ name: 'publishedAt', type: 'datetime' }),
defineField({ name: 'body', type: 'array', of: [{ type: 'block' }] }),
],
})
Then map each status to a colour:
// badges/StatusBadge.ts
import type { DocumentBadgeComponent } from 'sanity'
import { STATUSES } from '../schemaTypes/post'
const COLORS = {
writing: undefined,
review: 'warning',
approved: 'success',
changes: 'danger',
} as const
export const StatusBadge: DocumentBadgeComponent = (props) => {
const doc = props.draft ?? props.published
const status = doc?.status as keyof typeof COLORS | undefined
if (!status) return null
const title = STATUSES.find((s) => s.value === status)?.title ?? status
return {
label: title,
title: `Workflow status: ${title}`,
color: COLORS[status],
}
}
Reading draft ?? published means the badge reflects what the editor is looking at, including unpublished status changes. The initialValue sets new posts to "Writing"; initial values covers other ways to set defaults.
Badges for Data Quality
Badges are also a gentle way to flag problems that aren't strict validation errors:
// badges/QualityBadges.ts
import type { DocumentBadgeComponent } from 'sanity'
export const MissingImageBadge: DocumentBadgeComponent = ({ draft, published }) => {
const doc = draft ?? published
if (!doc || doc.mainImage) return null
return { label: 'No image', title: 'Add a main image before publishing', color: 'warning' }
}
export const StaleBadge: DocumentBadgeComponent = ({ published }) => {
if (!published?._updatedAt) return null
const ageInDays = (Date.now() - new Date(published._updatedAt).getTime()) / 86_400_000
if (ageInDays < 365) return null
return { label: 'Over a year old', title: 'Consider reviewing this content', color: 'danger' }
}
If something must block publishing, make it a validation rule instead. Badges inform; validation enforces.
Badges and Actions Together
A status badge becomes a real workflow when editors can change the status with a button. A pair of actions, "Submit for review" and "Approve", that patch the status field gives you a simple review process:
// actions/ReviewActions.tsx
import { useDocumentOperation, type DocumentActionComponent } from 'sanity'
export const SubmitForReviewAction: DocumentActionComponent = (props) => {
const { patch } = useDocumentOperation(props.id, props.type)
const doc = props.draft ?? props.published
if (doc?.status !== 'writing' && doc?.status !== 'changes') return null
return {
label: 'Submit for review',
onHandle: () => {
patch.execute([{ set: { status: 'review' } }])
props.onComplete()
},
}
}
export const ApproveAction: DocumentActionComponent = (props) => {
const { patch } = useDocumentOperation(props.id, props.type)
const doc = props.draft ?? props.published
if (doc?.status !== 'review') return null
return {
label: 'Approve',
tone: 'positive',
onHandle: () => {
patch.execute([{ set: { status: 'approved' } }])
props.onComplete()
},
}
}
// sanity.config.ts
document: {
badges: (prev, context) =>
context.schemaType === 'post' ? [...prev, StatusBadge, MissingImageBadge] : prev,
actions: (prev, context) =>
context.schemaType === 'post' ? [...prev, SubmitForReviewAction, ApproveAction] : prev,
},
Restrict who can approve by checking context.currentUser.roles in the actions resolver. The document actions guide shows that pattern and how to block Publish until a post is approved.
Showing Status in List Previews
Badges only appear inside an open document. To see status while scanning a list, put it in the type's preview configuration, which controls the title, subtitle and media shown in document lists, search results and reference pickers:
// schemaTypes/post.ts (continued)
import { CheckmarkCircleIcon, DocumentIcon, EditIcon, EyeOpenIcon, WarningOutlineIcon } from '@sanity/icons'
const STATUS_ICONS: Record<string, React.ComponentType> = {
writing: EditIcon,
review: EyeOpenIcon,
approved: CheckmarkCircleIcon,
changes: WarningOutlineIcon,
}
export const post = defineType({
// ...name, type and fields as above
preview: {
select: {
title: 'title',
status: 'status',
media: 'mainImage',
publishedAt: 'publishedAt',
},
prepare({ title, status, media, publishedAt }) {
const label = STATUSES.find((s) => s.value === status)?.title ?? 'No status'
const date = publishedAt ? new Date(publishedAt).toLocaleDateString('en-GB') : 'Unscheduled'
return {
title: title || 'Untitled',
subtitle: `${label} · ${date}`,
media: media ?? STATUS_ICONS[status] ?? DocumentIcon,
}
},
},
})
When a post has no image, the preview falls back to an icon for its status, so a glance down the list shows which posts are in review. The Structure tool already shows its own draft and published indicators on each row, so the subtitle is the right place for your workflow status. The full set of options, including following references, array values and sort-aware previews, is covered in customising list previews.
To go one step further, a custom desk structure can add list items like "In review" that filter by status, so reviewers get a queue rather than scanning all posts.
Preview Panes Beside the Editor
The other kind of preview shows the document as your website renders it. There are three options, from simplest to richest.
Production URL
document.productionUrl adds an "Open preview" item to the document's menu that opens your site in a new tab:
document: {
productionUrl: async (prev, context) => {
const { document } = context
const slug = (document.slug as { current?: string } | undefined)?.current
if (document._type === 'post' && slug) {
return `https://example.com/blog/${slug}`
}
return prev
},
},
An Iframe View
The sanity-plugin-iframe-pane plugin adds a tab next to the form that embeds your site in an iframe. You configure it with a default document node in the Structure tool:
// structure/defaultDocumentNode.ts
import type { DefaultDocumentNodeResolver } from 'sanity/structure'
import { Iframe } from 'sanity-plugin-iframe-pane'
export const defaultDocumentNode: DefaultDocumentNodeResolver = (S, { schemaType }) => {
if (schemaType === 'post') {
return S.document().views([
S.view.form(),
S.view
.component(Iframe)
.options({
url: (doc: { slug?: { current?: string } }) =>
doc?.slug?.current
? `http://localhost:3000/api/draft-mode/enable?redirect=/blog/${doc.slug.current}`
: 'http://localhost:3000',
reload: { button: true },
})
.title('Preview'),
])
}
return S.document().views([S.view.form()])
}
// sanity.config.ts
import { structureTool } from 'sanity/structure'
import { defaultDocumentNode } from './structure/defaultDocumentNode'
plugins: [structureTool({ defaultDocumentNode })],
Your front end must support draft previews for the iframe to show unpublished changes, and the enable route must be protected so drafts aren't public.
The Presentation Tool
For most new projects, the built-in Presentation tool is the better choice. It shows your site next to the form with click-to-edit overlays and updates as editors type. Setting it up is covered in visual editing with Next.js and live preview.
FAQ: Sanity Document Badges and Previews
A badge's color can be primary, success, warning or danger. If you leave it out, the badge uses a neutral default style.
No. Badges are display-only. To let editors change a status, add a custom document action that patches the field, and use the badge to show the result.
Badges belong to the document editor. To show the same information in document lists, add it to the type's preview configuration, for example as the subtitle or media returned from prepare.
Use the resolver form of document.badges, check context.schemaType and add your badge only for that type, returning prev unchanged for others.
Use validation when the problem must block publishing, and badges for softer signals such as a short word count or old content. Validation enforces rules, badges only inform.
A list preview is the compact row shown in document lists, configured with preview.select and prepare in the schema. A preview pane shows the document rendered by your website, using an iframe view or the Presentation tool.
Conclusion
Document badges are the lightest way to make status visible in Sanity Studio. A badge is a function that reads the draft or published document and returns a label and colour, registered in document.badges. Pair a status field with a badge and a couple of custom actions, and you have a working review workflow without any plugins.
Badges cover the open document, and previews cover everything else: list previews show the same status while editors scan lists, and preview panes or the Presentation tool show how the content will look on the site. Use all three together and editors always know what state a document is in and what it will look like once published.


