Skip to content

Latest commit

 

History

History

react

README.md

Sanity App SDK (React)

React hooks for creating Sanity applications. Live by default, optimistic updates, multi-project support.


Quickstart

1. Setup (2 min)

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, etc
  • src/App.tsx — Root with <SanityApp> provider and project configuration(s)
  • src/ExampleComponent.tsx — Your starting point

2. Project configuration

import {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.

3. Generate types (beta)

Configure TypeGen before writing components so hooks return typed data. Add typegen.resources to sanity.cli.ts and run npx sanity typegen generate --watch. See TypeScript & TypeGen for the steps.


Guide

Document Handles

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.


Hook Reference

Data Retrieval

// 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 }`,
})

Document Manipulation

// Create a new document and get back its handle
const createArticle = useCreateDocument({documentType: 'article'})
const newHandle = await createArticle({title: 'Untitled', status: 'draft'})

// 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)])

Events & Permissions

// Subscribe to document events
useDocumentEvent({
  ...handle,
  onEvent: (event) => {
    // event.type: 'edited' | 'published' | 'deleted' | 'created' | 'unpublished' | 'discarded' | ...
    console.log(event.type, event.documentId)
  },
})

// Check permissions
const {data: canEdit} = useDocumentPermissions({
  ...handle,
  permission: 'update',
})
const {data: canPublish} = useDocumentPermissions({
  ...handle,
  permission: 'publish',
})

Document Actions

The useApplyDocumentActions hook is used to perform document lifecycle operations. Actions are created using helper functions and applied through the apply function.

Available Action Creators

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

Creating Documents

useCreateDocument handles the common case: it generates the document ID for you and returns the new document's handle, ready to pass to useDocument, useEditDocument, or your router.

import {useCreateDocument} from '@sanity/sdk-react'

function CreateArticleButton() {
  const createArticle = useCreateDocument({documentType: 'article'})

  const handleCreateArticle = async () => {
    const handle = await createArticle({
      title: 'New Article',
      status: 'draft',
      author: {_type: 'reference', _ref: 'author-123'},
    })

    // Navigate to the new document using the returned handle
    navigate(`/articles/${handle.documentId}`)
  }

  return <button onClick={handleCreateArticle}>Create Article</button>
}

To use a specific ID instead of a generated one, pass it on the handle (useCreateDocument({documentType: 'article', documentId})) or per call (createArticle(initialValue, {documentId})).

For atomic create-and-publish, or to create several documents in a single transaction, drop down to useApplyDocumentActions with the createDocument and publishDocument action creators (see Batch Operations below).

Publishing Documents

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>
  )
}

Deleting Documents

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>
}

Batch Operations

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)])

Suspense Pattern

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>
  )
}

Viewport-Based Lazy Loading

Pass a ref to useDocumentProjection to fetch only when the element enters the viewport. Wrap the component in Suspense like any other data hook:

import {useRef} from 'react'

function LazyArticleCard({handle}: {handle: DocumentHandle}) {
  const ref = useRef<HTMLDivElement>(null)
  const {data} = useDocumentProjection({
    ...handle,
    ref, // Only fetches when element is visible
    projection: '{ title }',
  })

  return <div ref={ref}>{data?.title}</div>
}

Draft/Published Model

Sanity has two document states:

  • Published: _id: "abc123" — live, public
  • Draft: _id: "drafts.abc123" — working copy

The SDK handles updating the document state automatically:

  • useDocument() returns draft if exists, else published
  • useEditDocument() creates draft on first edit (automatic)
  • publishDocument() copies draft → published, deletes draft
  • discardDraft() deletes draft, reverts to published

LiveEdit Documents

For documents that don't need the draft/published workflow (such as settings, configuration, or real-time collaborative documents), you can use liveEdit mode by setting liveEdit: true in the document handle:

const settingsHandle: DocumentHandle = {
  documentId: 'site-settings',
  documentType: 'settings',
  liveEdit: true, // Edits apply directly without creating a draft
}

// Edits are applied immediately to the published document
const editSettings = useEditDocument(settingsHandle)

When using liveEdit documents:

  • Drafts will not be created when the document is edited
  • Edits will be applied directly to the published document
  • publishDocument(), unpublishDocument(), and discardDraft() actions cannot be used (since liveEdit documents are always published and do not have drafts)

For more details, see the Sanity documentation on liveEdit documents.


Real-Time Behavior

Live by Default

  • Document changes from other users appear instantly
  • No polling, uses Sanity's listener API
  • Optimistic updates for local edits appear before the server confirms the updates

Re-render Triggers

Any 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.


Multi-Project Access

The SDK supports accessing documents from multiple projects and datasets simultaneously. There are two main approaches:

Approach 1: Specify Project/Dataset Directly in the Handle

Pass projectId and dataset directly in document handles to fetch data from specific projects (note that any projectId and dataset pair you pass must be defined in your application’s array of SanityConfig objects):

import {useDocument} from '@sanity/sdk-react'

function MultiProjectComponent() {
  // Fetch from Project A
  const {data: productA} = useDocument({
    documentId: 'product-123',
    documentType: 'product',
    projectId: 'project-a',
    dataset: 'production',
  })

  // Fetch from Project B
  const {data: productB} = useDocument({
    documentId: 'product-456',
    documentType: 'product',
    projectId: 'project-b',
    dataset: 'staging',
  })

  return (
    <div>
      <h2>{productA?.title} (Project A)</h2>
      <h2>{productB?.title} (Project B)</h2>
    </div>
  )
}

Approach 2: Use ResourceProvider to Set Context

Wrap components in ResourceProvider to set default project/dataset values for all child components:

// App.tsx
import {ResourceProvider, useDocument, useSanityInstance} from '@sanity/sdk-react'

function ProductCard({productId}: {productId: string}) {
  // Get the current project/dataset from context
  const {config} = useSanityInstance()

  // No need to specify projectId/dataset - inherited from ResourceProvider
  const {data: product} = useDocument({
    documentId: productId,
    documentType: 'product',
  })

  return (
    <div>
      <h3>{product?.title}</h3>
      <p>
        From: {config.projectId}.{config.dataset}
      </p>
    </div>
  )
}

export function MultiProjectApp() {
  return (
    <div>
      {/* Products from Project A */}
      <ResourceProvider projectId="project-a" dataset="production" fallback={<div>Loading...</div>}>
        <h2>Project A Products</h2>
        <ProductCard productId="product-123" />
        <ProductCard productId="product-456" />
      </ResourceProvider>

      {/* Products from Project B */}
      <ResourceProvider projectId="project-b" dataset="staging" fallback={<div>Loading...</div>}>
        <h2>Project B Products</h2>
        <ProductCard productId="product-789" />
      </ResourceProvider>
    </div>
  )
}

Key Points:

  • When using hooks that take document handles as arguments (such useDocument, useEditDocument, useQuery, etc.), the document handles’ projectId and dataset values can be explicitly set to fetch documents from arbitrary projects and datasets
  • The ResourceProvider component is used to create a project ID and dataset context that child components will inherit from; this can negate the need to specify the project ID and dataset values for document handles in hooks called by child components
  • Use useSanityInstance() to access the context configuration for the current component: const {config} = useSanityInstance()
  • You can nest ResourceProvider components to create component trees with different project/dataset configurations — but be aware that, when the project ID and dataset values for document handles are not specified, the project ID and dataset from the closest ResourceProvider context will be used
  • Regardless of the approach you use, the project IDs and dataset names you reference (whether in document handles or ResourceProviders) must be enumerated in your application’s SanityConfig objects

Using the SDK inside Sanity Studio

The SDK can be embedded directly inside a Sanity Studio with zero manual configuration. Sanity Studio provides SDKStudioContext automatically, so SanityApp derives projectId, dataset, and auth from the Studio's workspace without any setup.

Zero-config setup (recommended)

Sanity Studio automatically provides SDKStudioContext to SDK components, so your SDK component needs no config prop at all:

import {SanityApp} from '@sanity/sdk-react'

// Inside a Sanity Studio — no config needed:
function MyStudioTool() {
  return (
    <SanityApp fallback={<div>Loading...</div>}>
      <MyComponent />
    </SanityApp>
  )
}

Under the hood, the Studio wraps its component tree with SDKStudioContext.Provider, passing its workspace to the SDK:

import {SDKStudioContext} from '@sanity/sdk-react'
import {useWorkspace} from 'sanity'

// This is done automatically by Sanity Studio — shown here for reference only
function StudioSDKWrapper({children}) {
  const workspace = useWorkspace()
  return <SDKStudioContext.Provider value={workspace}>{children}</SDKStudioContext.Provider>
}

Explicit config takes precedence

If you pass a config prop to SanityApp, this config will take precedence over any workspace config picked up by SDKStudioContext:

// This uses the explicit config, not the Studio workspace
<SanityApp config={{projectId: 'other-project', dataset: 'staging'}} fallback={<Loading />}>
  <MyComponent />
</SanityApp>

Reactive auth token sync

If the Studio provides a reactive token source via workspace.auth.token, the SDK subscribes to it and stays in sync automatically. The Studio remains the single authority for auth — the SDK does not perform its own token refresh.

For older Studios that don't expose a token source, the SDK falls back to discovering the auth token from localStorage or cookie auth.

Migrating from studioMode

The studioMode config field is deprecated. If you are currently using it, the recommended replacement is to use the zero-config SDKStudioContext approach described above — which requires no config prop at all.

If you need to pass an explicit config, replace studioMode with studio:

 const config: SanityConfig = {
   projectId: 'my-project',
   dataset: 'production',
-  studioMode: { enabled: true },
+  studio: {},
 }

TypeScript & TypeGen

Set up TypeGen when you create an app. Hooks then infer document, field, and query result types from the handle, so you don't write or pass result types yourself.

This workflow is in beta. It requires @sanity/sdk-react 3.9.0 or later and @sanity/cli 8.16.0 or later. See the TypeScript guide for every option and for troubleshooting.

1. Extract each dataset's schema. Run this in the Studio that defines the schema, then copy the file into your app:

npx sanity schema extract --path schema.production.json

2. Declare your datasets in a module that the app and the CLI both import. as const keeps the project ID and dataset as literal types, which inference needs. The CLI loads this file in Node, so it must not use browser globals.

// src/resources.ts
import type {DocumentResource} from '@sanity/sdk-react'

export const resources = {
  production: {projectId: 'abc123', dataset: 'production'},
} as const satisfies Record<string, DocumentResource>

3. Add typegen.resources to sanity.cli.ts. Each entry writes one types file for one dataset:

// sanity.cli.ts
import {defineCliConfig} from 'sanity/cli'

import {resources} from './src/resources'

export default defineCliConfig({
  app: {organizationId: 'your-org-id', entry: './src/App.tsx'},
  typegen: {
    resources: [
      {
        ...resources.production,
        schema: './schema.production.json',
        generates: './src/sanity.types.production.ts',
      },
    ],
  },
})

4. Generate types. Run it again after changing a schema or query, or add --watch (@sanity/cli 8.22.0 or later) to regenerate on every change. sanity dev does not generate types for typegen.resources, so run the watcher alongside it.

npx sanity typegen generate
npx sanity typegen generate --watch

5. Use hooks without type parameters. Build handles and options from your declared resources:

import {createDocumentHandle, useDocument, useQuery} from '@sanity/sdk-react'
import {defineQuery} from 'groq'

import {resources} from './resources'

const book = createDocumentHandle({
  ...resources.production,
  documentId: 'book-1',
  documentType: 'book',
})

const bookTitlesQuery = defineQuery('*[_type == "book"]{_id, title}')

export function BookTitle() {
  // `Book` from sanity.types.production.ts, or null
  const {data: doc} = useDocument(book)
  return <h1>{doc?.title}</h1>
}

export function BookTitles() {
  // the generated result type for bookTitlesQuery
  const {data} = useQuery({...resources.production, query: bookTitlesQuery})
  return (
    <ul>
      {data.map((b) => (
        <li key={b._id}>{b.title}</li>
      ))}
    </ul>
  )
}

defineQuery comes from the groq package (6.12.0 or later). Apps created from the app-quickstart template don't include it, so run npm install groq first. Assign each query to a variable so TypeGen can find it.

Typed .groq files (experimental)

useQuery, getQueryState and resolveQuery read the result and params types off a query imported from a .groq file through @sanity-labs/groq-content-mapper, so there's no generic and no registry entry to keep in sync. This is experimental and needs TypeScript 7.1 or later, type-checking with tsc --noEmit --runExternalCode, and a schema.json from npx sanity schema extract in your Studio project. The mapper's README covers the details.

The brand carries no project or dataset and overrides the handle, like an explicit generic. A file typed against one dataset keeps that dataset's types wherever you pass it. Keep each dataset's .groq files in that dataset's schemas folder.

// tsconfig.json
{
  "compilerOptions": {/* ... */},
  "contentMappers": [
    {
      "package": "@sanity-labs/groq-content-mapper",
      "extensions": [".groq"],
      "options": {
        "schema": "./schema.json",
        // Needed when the app queries more than one project or dataset: one schema per folder
        // "schemas": [{"include": "src/queries/shop", "schema": "./shop.schema.json"}],
      },
    },
  ],
}
// sanity.cli.ts
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
  app: {
    organizationId: 'your-org-id',
    entry: './src/App.tsx',
  },
  // Delivers `.groq` imports as strings in `sanity dev` and `sanity build`
  vite: async (viteConfig) => {
    const {default: groqFiles} = await import('@sanity-labs/groq-content-mapper/vite')
    return {
      ...viteConfig,
      plugins: [...viteConfig.plugins, groqFiles()],
    }
  },
})
import movieById from './movieById.groq' with {type: 'text'}

const {data} = useQuery({query: movieById, params: {id: movieId}})
// data is typed from the query, and params are checked

Deployment

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.


UI Options

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 Setup

Tailwind requires a few extra steps since the App SDK uses Vite internally.

  1. Install dependencies:
npm install tailwindcss @tailwindcss/vite
  1. Configure the Vite plugin in 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()],
    }
  },
})
  1. Import Tailwind in your CSS (e.g., src/App.css):
@import 'tailwindcss';
  1. Import the CSS in your app:
// src/App.tsx
import './App.css'

Now you can use Tailwind classes in your components.

Portable Text Editor

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.

  1. Install dependencies:
npm install @portabletext/editor @portabletext/plugin-sdk-value
  1. Use in a component:
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>
  )
}
SDKValuePlugin Props
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:

  • Two-way sync between editor and document
  • Real-time updates from other users
  • Optimistic updates for smooth UX

Common Patterns

Editable List Item

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)} />
}

Publish Button with Permission Check

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>
}

Document Status Indicator

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>
}

Quick Reference

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
Comments useComments, useCommentThreads, useCommentsQuery, useCommentActions, from @sanity/sdk-react/collaboration

Comments have their own guide: Collaboration.


Documentation

License

MIT © Sanity.io