React hooks for creating Sanity applications. Live by default, optimistic updates, multi-project support.
npx sanity@latest init --template app-quickstart
cd your-app
npm run dev
Opens at https://www.sanity.io/welcome?dev=http%3A%2F%2Flocalhost%3A3333, proxied through Sanity Dashboard for auth.
Key files:
sanity.cli.ts — configuration options used by the CLI — application metadata, deployment config, etcsrc/App.tsx — Root with <SanityApp> provider and project configuration(s)src/ExampleComponent.tsx — Your starting pointimport {SanityApp, type SanityConfig} from '@sanity/sdk-react'
const config: SanityConfig[] = [
{projectId: 'abc123', dataset: 'production'},
{projectId: 'def456', dataset: 'production'}, // multi-project support
]
export function App() {
return (
<SanityApp config={config} fallback={<div>Loading...</div>}>
<YourApp />
</SanityApp>
)
}
Auth is automatic — Dashboard injects an auth token via iframe. No custom login flow is needed for your application.
Document handles are a core concept for apps built with the App SDK. Document handles are minimal pointers to documents. They consist of the following properties:
type DocumentHandle = {
documentId: string
documentType: string
projectId?: string // optional if using the default projectId or inside a ResourceProvider
dataset?: string // optional if using the default dataset or inside a ResourceProvider
}
Best practice: Fetch document handles first → pass them to child components → fetch individual document content from child components.
// Get a collection of document handles (structured for infinite scrolling)
const {data, hasMore, loadMore, isPending, count} = useDocuments({
documentType: 'article',
batchSize: 20,
orderings: [{field: '_updatedAt', direction: 'desc'}],
filter: 'status == $status', // GROQ filter
params: {status: 'published'}, // Parameters used for the GROQ filter
})
// Get a collection of document handles (structured for paginated lists)
const {data, currentPage, totalPages, nextPage, previousPage} = usePaginatedDocuments({
documentType: 'article',
pageSize: 10,
})
// Get content from a single document (live content, optimistic updates when used with useEditDocument)
const {data: doc} = useDocument(handle)
const {data: title} = useDocument({...handle, path: 'title'})
// Get a projection for an individual document (live content, no optimistic updates)
const {data} = useDocumentProjection({
...handle,
projection: `{ title, "author": author->name, "imageUrl": image.asset->url }`,
})
// Use GROQ directly
const {data} = useQuery({
query: `*[_type == "article" && featured == true][0...5]{ title, slug }`,
})
// Edit field (emits optimistic updates to useEditDocument listeners, creates a draft automatically)
const editTitle = useEditDocument({...handle, path: 'title'})
editTitle('New Title') // fires on every keystroke, debounced internally
// Edit a nested path in a document
const editAuthorName = useEditDocument({...handle, path: 'author.name'})
// Document actions
import {
useApplyDocumentActions,
createDocumentHandle,
publishDocument,
unpublishDocument,
deleteDocument,
createDocument,
discardDraft,
} from '@sanity/sdk-react'
const apply = useApplyDocumentActions()
// Single action
await apply(publishDocument(handle))
// Batch actions
await apply([publishDocument(handle1), publishDocument(handle2), deleteDocument(handle3)])
// Create new document with an optional initial content
const newHandle = createDocumentHandle({
documentId: crypto.randomUUID(),
documentType: 'article',
})
await apply(createDocument(newHandle, {title: 'Untitled', status: 'draft'}))
// Subscribe to document events
useDocumentEvent({
...handle,
onEvent: (event) => {
// event.type: 'documentEdited' | 'documentPublished' | 'documentDeleted' | ...
console.log(event.type, event.documentId)
},
})
// Check permissions
const {data: canEdit} = useDocumentPermissions({
...handle,
permission: 'update',
})
const {data: canPublish} = useDocumentPermissions({
...handle,
permission: 'publish',
})
The useApplyDocumentActions hook is used to perform document lifecycle operations. Actions are created using helper functions and applied through the apply function.
| Function | Description |
|---|---|
createDocument |
Create a new document |
publishDocument |
Publish a draft (copy draft → published) |
unpublishDocument |
Unpublish (delete published, keep draft) |
deleteDocument |
Delete document entirely (draft and published) |
discardDraft |
Discard draft changes, revert to published |
To create a document, you must:
crypto.randomUUID())createDocumentHandlecreateDocument action using the document handle, along with optional initial contentimport {useApplyDocumentActions, createDocumentHandle, createDocument} from '@sanity/sdk-react'
function CreateArticleButton() {
const apply = useApplyDocumentActions()
const handleCreateArticle = () => {
const newId = crypto.randomUUID()
const handle = createDocumentHandle({
documentId: newId,
documentType: 'article',
})
apply(
createDocument(handle, {
title: 'New Article',
status: 'draft',
author: {_type: 'reference', _ref: 'author-123'},
}),
)
// Navigate to the new document
navigate(`/articles/${newId}`)
}
return <button onClick={handleCreateArticle}>Create Article</button>
}
import {useApplyDocumentActions, publishDocument, useDocument} from '@sanity/sdk-react'
function PublishButton({handle}: {handle: DocumentHandle}) {
const apply = useApplyDocumentActions()
const {data: doc} = useDocument(handle)
// Check if document has unpublished changes (is a draft)
const isDraft = doc?._id?.startsWith('drafts.')
return (
<button disabled={!isDraft} onClick={() => apply(publishDocument(handle))}>
Publish
</button>
)
}
import {useApplyDocumentActions, deleteDocument} from '@sanity/sdk-react'
function DeleteButton({handle}: {handle: DocumentHandle}) {
const apply = useApplyDocumentActions()
const handleDelete = () => {
if (confirm('Are you sure?')) {
apply(deleteDocument(handle))
}
}
return <button onClick={handleDelete}>Delete</button>
}
Apply multiple actions as a single transaction:
const apply = useApplyDocumentActions()
// Create and immediately publish
const newHandle = createDocumentHandle({
documentId: crypto.randomUUID(),
documentType: 'article',
})
apply([createDocument(newHandle, {title: 'Breaking News'}), publishDocument(newHandle)])
// Publish multiple documents at once
apply([publishDocument(handle1), publishDocument(handle2), publishDocument(handle3)])
All hooks that get or write data use React Suspense. Wrap all your components that fetch data with a Suspense boundary to avoid unnecessary re-renders:
function App() {
return (
<Suspense fallback={<Skeleton />}>
<ArticleList />
</Suspense>
)
}
function ArticleList() {
const {data: articles} = useDocuments({documentType: 'article'})
return (
<ul>
{articles.map((handle) => (
{/* Wrap each list item in its own Suspense boundary to prevent full list re-renders when one item updates */}
<Suspense key={handle.documentId} fallback={<li>Loading...</li>}>
<ArticleItem handle={handle} />
</Suspense>
))}
</ul>
)
}
Sanity has two document states:
_id: "abc123" — live, public_id: "drafts.abc123" — working copyThe SDK handles updating the document state automatically:
useDocument() returns draft if exists, else publisheduseEditDocument() creates draft on first edit (automatic)publishDocument() copies draft → published, deletes draftdiscardDraft() deletes draft, reverts to publishedAny mutation to a subscribed document (even fields you don't display) will trigger a re-render. Use useDocumentProjection() for read-only displays to minimize re-renders.
const handle: DocumentHandle = {
documentId: 'xyz',
documentType: 'product',
projectId: 'project-a',
dataset: 'production',
}
// App.tsx
import {ResourceProvider} from '@sanity/sdk-react'
import {ProductCard} from './ProductCard'
export function WrappedProductCard() {
return (
<ResourceProvider projectId="project-a" dataset="production">
<ProductCard productId="xyz" />
</ResourceProvider>
)
}
// ProductCard.tsx
import {useProjectId, useDataset} from '@sanity/sdk-react'
function ProductCard({productId}: {productId: string}) {
const projectId = useProjectId() // "project-a" from nearest configured ResourceProvider
const dataset = useDataset() // "production" from nearest configured ResourceProvider
// ...
}
# Generate types from your schema
npx sanity typegen generate
import type {Article} from './sanity.types'
const {data} = useDocument<Article>(handle)
// data is typed as Article
npx sanity deploy
Add the resulting app ID to the deployment section of your sanity.config.ts file: {deployment: { appId: "appbc1234", ... } }.
App appears in Sanity Dashboard alongside Studios. Requires sanity.sdk.applications.deploy permission.
SDK is headless. Common choices:
# Sanity UI (matches Studio aesthetic)
npm install @sanity/ui @sanity/icons styled-components
# Tailwind
npm install tailwindcss @tailwindcss/vite
Tailwind requires a few extra steps since the App SDK uses Vite internally.
npm install tailwindcss @tailwindcss/vite
sanity.cli.ts:import {defineCliConfig} from 'sanity/cli'
export default defineCliConfig({
app: {
organizationId: 'your-org-id',
entry: './src/App.tsx',
},
vite: async (viteConfig) => {
const {default: tailwindcss} = await import('@tailwindcss/vite')
return {
...viteConfig,
plugins: [...viteConfig.plugins, tailwindcss()],
}
},
})
src/App.css):@import 'tailwindcss';
// src/App.tsx
import './App.css'
Now you can use Tailwind classes in your components.
Use @portabletext/plugin-sdk-value to connect a Portable Text Editor with a Sanity document field. It provides two-way sync, real-time collaboration, and optimistic updates.
npm install @portabletext/editor @portabletext/plugin-sdk-value
import {defineSchema, EditorProvider, PortableTextEditable} from '@portabletext/editor'
import {SDKValuePlugin} from '@portabletext/plugin-sdk-value'
function MyEditor({documentId}: {documentId: string}) {
return (
<EditorProvider initialConfig={{schemaDefinition: defineSchema({})}}>
<PortableTextEditable />
<SDKValuePlugin documentId={documentId} documentType="article" path="content" />
</EditorProvider>
)
}
| Prop | Type | Description |
|---|---|---|
documentId |
string |
The document ID |
documentType |
string |
The document type |
path |
string |
JSONMatch path to the Portable Text field |
dataset |
string (optional) |
Dataset name if different from default |
projectId |
string (optional) |
Project ID if different from default |
The plugin handles:
function EditableTitle({handle}: {handle: DocumentHandle}) {
const {data: title} = useDocument({...handle, path: 'title'})
const editTitle = useEditDocument({...handle, path: 'title'})
return <input value={title ?? ''} onChange={(e) => editTitle(e.target.value)} />
}
function PublishButton({handle}: {handle: DocumentHandle}) {
const {data: canPublish} = useDocumentPermissions({
...handle,
permission: 'publish',
})
const apply = useApplyDocumentActions()
if (!canPublish) return null
return <button onClick={() => apply(publishDocument(handle))}>Publish</button>
}
function DocStatus({handle}: {handle: DocumentHandle}) {
const {data: published} = useDocumentProjection({
documentId: handle.documentId, // without drafts. prefix
documentType: handle.documentType,
projection: '{ _updatedAt }',
})
const {data: draft} = useDocumentProjection({
documentId: `drafts.${handle.documentId}`,
documentType: handle.documentType,
projection: '{ _updatedAt }',
})
if (draft && published) return <span>Modified</span>
if (draft) return <span>Draft</span>
if (published) return <span>Published</span>
return <span>New</span>
}
| Task | Hook/Function |
|---|---|
| List documents | useDocuments, usePaginatedDocuments |
| Read document | useDocument, useDocumentProjection |
| Edit field | useEditDocument |
| Publish/Delete/Create | useApplyDocumentActions + action creators |
| GROQ query | useQuery |
| Check permissions | useDocumentPermissions |
| Listen to changes | useDocumentEvent |
MIT © Sanity.io