How I build an unpublish and archive workflow in Sanity
Jun 30, 2026 · 6 min read
The default Sanity unpublish document action does one thing: it unpublishes and leaves a draft sitting in the Studio. That's fine for a quick takedown, but it doesn't tell you why the document is off the site, and it gives editors no way to distinguish 'needs rework' from 'permanently retired'. Here's the three-action workflow I land on for most client projects: Unpublish, Archive, and Restore — all without touching the delete button.
Why not just delete
Deletion is irreversible without a dataset export. Clients also regularly want to restore seasonal content or repurpose old articles. I keep every document alive in the dataset and use a status field to control visibility. The published/draft lifecycle stays intact; I'm only adding a layer on top.
The schema change is small: a single status field on every document type that needs this behaviour.
// schemas/shared/statusField.ts
import { defineField } from 'sanity'
export const statusField = defineField({
name: 'status',
title: 'Status',
type: 'string',
options: {
list: [
{ title: 'Active', value: 'active' },
{ title: 'Archived', value: 'archived' },
],
layout: 'radio',
},
initialValue: 'active',
// Hide from editors — only document actions should mutate this.
hidden: true,
})Adding hidden: true means editors never touch it directly. Only the custom actions do, which prevents accidental state drift.
The three custom document actions
Sanity's document actions API lets you replace or extend the default action list. I register all three actions in sanity.config.ts and scope them to content types that need archiving (usually post, page, caseStudy).
// document-actions/archiveActions.ts
import {
DocumentActionComponent,
DocumentActionProps,
useDocumentOperation,
} from 'sanity'
// 1. Unpublish — removes from live site, keeps draft, status stays 'active'
export const UnpublishAction: DocumentActionComponent = (
props: DocumentActionProps,
) => {
const { unpublish } = useDocumentOperation(props.id, props.type)
return {
label: 'Unpublish',
tone: 'caution',
disabled: !props.published,
onHandle: () => {
unpublish.execute()
props.onComplete()
},
}
}
// 2. Archive — unpublishes AND sets status:'archived' so front end can 404 / redirect
export const ArchiveAction: DocumentActionComponent = (
props: DocumentActionProps,
) => {
const { unpublish, patch } = useDocumentOperation(props.id, props.type)
return {
label: 'Archive',
tone: 'critical',
disabled: props.draft?.status === 'archived' || props.published?.status === 'archived',
onHandle: () => {
// Patch the draft (or create one) then unpublish
patch.execute([{ set: { status: 'archived' } }])
// Unpublish fires after patch is staged
setTimeout(() => {
unpublish.execute()
props.onComplete()
}, 300)
},
}
}
// 3. Restore — flips status back to 'active', does NOT auto-publish
export const RestoreAction: DocumentActionComponent = (
props: DocumentActionProps,
) => {
const { patch } = useDocumentOperation(props.id, props.type)
return {
label: 'Restore',
tone: 'positive',
disabled:
props.draft?.status !== 'archived' && props.published?.status !== 'archived',
onHandle: () => {
patch.execute([{ set: { status: 'active' } }])
props.onComplete()
},
}
}Wire them into sanity.config.ts:
// sanity.config.ts (excerpt)
import { defineConfig } from 'sanity'
import { UnpublishAction, ArchiveAction, RestoreAction } from './document-actions/archiveActions'
const ARCHIVABLE_TYPES = ['post', 'page', 'caseStudy']
export default defineConfig({
// ...projectId, dataset, plugins
document: {
actions: (prev, { schemaType }) => {
if (!ARCHIVABLE_TYPES.includes(schemaType)) return prev
// Remove Sanity's built-in unpublish; replace with our three actions
const withoutBuiltInUnpublish = prev.filter(
(action) => action.action !== 'unpublish',
)
return [...withoutBuiltInUnpublish, UnpublishAction, ArchiveAction, RestoreAction]
},
},
})The setTimeout(300) in ArchiveAction is a pragmatic workaround: patch stages a mutation and unpublish stages another, and firing them in the same tick can race. Three hundred milliseconds is well inside Sanity's transaction window and has been reliable across the datasets I've tested.
Handling archived docs in Next.js
Now the interesting part. An archived document has no published version — unpublish removed it — but the draft carries status: 'archived'. The front end never sees that draft in normal queries. What you need is a catch in the route that was previously serving the document.
In the app/blog/[slug]/page.tsx route I run two GROQ queries: the standard published fetch, and a lightweight check for whether a slug ever existed as an archived doc.
// app/blog/[slug]/page.tsx
import { notFound, redirect } from 'next/navigation'
import { client } from '@/sanity/client'
const POST_QUERY = `
*[_type == "post" && slug.current == $slug && !(_id in path("drafts.**"))][0]{
_id, title, slug, status
}
`
// Queries the *draft* namespace — only accessible server-side with a token
const ARCHIVED_CHECK_QUERY = `
*[_type == "post" && slug.current == $slug && _id in path("drafts.**") && status == "archived"][0]{
_id
}
`
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await client.fetch(POST_QUERY, { slug })
if (!post) {
// Check whether this was intentionally archived vs never existed
const archivedPost = await client
.withConfig({ token: process.env.SANITY_API_READ_TOKEN, useCdn: false })
.fetch(ARCHIVED_CHECK_QUERY, { slug })
if (archivedPost) {
// Permanent redirect to a relevant listing page rather than a hard 404
redirect('/blog')
}
notFound()
}
// Normal render...
return <article>{post.title}</article>
}The key detail: ARCHIVED_CHECK_QUERY targets drafts.** documents, which are only visible with a server-side token. The CDN never exposes them. A read-only viewer token scoped to the dataset is enough — I set SANITY_API_READ_TOKEN in Vercel environment variables and never expose it client-side.
The distinction between redirect('/blog') and notFound() matters for SEO. A slug that was genuinely published and indexed deserves a 301-equivalent (Next.js redirect sends 307 by default — you can use permanentRedirect for 308). A slug that never existed should 404. Googlebot treats them differently and your Search Console will thank you.
Revalidation after archiving
Archiving fires no Sanity webhook by default because unpublishing triggers the document.unpublish event, not a content change event. I already have a webhook route handling create, update, and delete events; I add publish and unpublish to the event filter in the Sanity dashboard and tag-revalidate the affected slug:
_type == "post"Revalidation logic in the route handler reads _id and slug.current from the webhook payload, then calls revalidateTag(post-$). The archived page clears from the Next.js cache within seconds of the editor clicking Archive.
What editors see
In Studio, the action bar now shows Publish, Archive, and Restore for content types in ARCHIVABLE_TYPES. The built-in Unpublish is still available for non-archivable types. Editors don't see the status field directly — they just click Archive, and the document disappears from the live site. Restore brings it back to draft for review before a second publish cycle.
This setup has handled content lifecycle cleanly across three production sites. No data loss, clear editor intent, and the front end degrades gracefully whether a slug was archived or never existed.