Files
johannes.gasser 57af0b8386
Build and Deploy / build-and-deploy (push) Successful in 2m53s
add skills
2026-05-18 08:39:42 +02:00

11 KiB

title, description
title description
Sanity App SDK Rules for building custom applications with the Sanity App SDK, including React hooks, document handles, real-time patterns, and Suspense best practices.

Sanity App SDK

Build custom React applications that interact with Sanity content in real-time.

Tech Stack

  • Framework: React 19+, TypeScript
  • Packages: @sanity/sdk, @sanity/sdk-react
  • Optional UI: @sanity/ui, styled-components
  • Runtime: Node.js 20+

Commands

# Basic quickstart
npx sanity@latest init --template app-quickstart --organization <your-org-id> --output-path . --typescript --skip-mcp

# With Sanity UI components
npx sanity@latest init --template app-sanity-ui --organization <your-org-id> --output-path . --typescript --skip-mcp

# Start development server
npm run dev

# Deploy to Sanity
npx sanity@latest deploy

# Install Sanity UI
npm install @sanity/ui styled-components

Project Structure

my-app/
├── sanity.cli.ts        # CLI config (org ID, entry point)
├── src/
│   ├── App.tsx          # Root component with SanityApp provider
│   ├── App.css          # Global styles
│   └── components/      # Your components
├── package.json
└── tsconfig.json

Boundaries

  • Always: Wrap data-fetching components in <Suspense>, use documentId as React key, read/write directly to Content Lake (not local state)
  • Always: Use useDocuments for lists, useDocumentProjection for display, useDocument + useEditDocument for editing
  • Ask first: Before using useQuery with raw GROQ (prefer useDocuments + useDocumentProjection)
  • Ask first: Before adding multiple data-fetching hooks in a single component
  • Never: Use useState for form values that should sync with Content Lake
  • Never: Use array index as React key for document lists (breaks real-time updates)
  • Never: Forget the fallback prop on <SanityApp> and <Suspense> boundaries

Configuration

CLI Config (sanity.cli.ts)

import { defineCliConfig } from 'sanity/cli'

export default defineCliConfig({
  app: {
    organizationId: 'your-org-id',
    entry: './src/App.tsx',
  },
})

App Root (src/App.tsx)

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

export default function App() {
  const config: SanityConfig[] = [
    {
      projectId: 'your-project-id',
      dataset: 'production',
    },
  ]

  return (
    <SanityApp config={config} fallback={<div>Loading...</div>}>
      <YourComponents />
    </SanityApp>
  )
}

With Sanity UI

import { SanityApp, type SanityConfig } from '@sanity/sdk-react'
import { ThemeProvider } from '@sanity/ui'
import { buildTheme } from '@sanity/ui/theme'

const theme = buildTheme()

export default function App() {
  const config: SanityConfig[] = [
    { projectId: 'your-project-id', dataset: 'production' },
  ]

  return (
    <ThemeProvider theme={theme}>
      <SanityApp config={config} fallback={<div>Loading...</div>}>
        <YourComponents />
      </SanityApp>
    </ThemeProvider>
  )
}

Environment Variables

Prefix with SANITY_APP_ for automatic bundling:

SANITY_APP_PROJECT_ID=abc123
SANITY_APP_DATASET=production

Access: process.env.SANITY_APP_PROJECT_ID


Document Handles

Lightweight references to documents. Fetch handles first, then load content as needed.

interface DocumentHandle {
  documentId: string
  documentType: string
  projectId?: string
  dataset?: string
}

Creating Handles

// Best: From useDocuments hook
const { data: handles } = useDocuments({ documentType: 'article' })

// Good: With helper (preserves literal types for TypeGen)
import { createDocumentHandle } from '@sanity/sdk'
const handle = createDocumentHandle({
  documentId: 'my-doc-id',
  documentType: 'article',
})

// Good: With as const (preserves literal types)
const handle = {
  documentId: 'my-doc-id',
  documentType: 'article',
} as const

Hook Selection

Hook Use Case Returns
useDocuments List of documents (infinite scroll) Document handles
usePaginatedDocuments Paginated lists with page controls Document handles
useDocument Single document, real-time editing Full document or field
useDocumentProjection Specific fields, display only Projected data
useQuery Complex GROQ queries (use sparingly) Raw query results

Code Patterns

Fetching a Document List

// Good: Fetch handles, render items with Suspense
import { Suspense } from 'react'
import { useDocuments } from '@sanity/sdk-react'

function ArticleList() {
  const { data, hasMore, loadMore, isPending } = useDocuments({
    documentType: 'article',
    batchSize: 10,
    orderings: [{ field: '_updatedAt', direction: 'desc' }],
  })

  return (
    <>
      <ul>
        {data.map((handle) => (
          <Suspense key={handle.documentId} fallback={<li>Loading...</li>}>
            <ArticleItem {...handle} />
          </Suspense>
        ))}
      </ul>
      {hasMore && (
        <button onClick={loadMore} disabled={isPending}>
          Load More
        </button>
      )}
    </>
  )
}
// Bad: Over-fetching with raw GROQ, no pagination
function BadArticleList() {
  const { data } = useQuery(`*[_type == "article"]`)
  return data?.map((doc, i) => <li key={i}>{doc.title}</li>)
}

Projecting Content from a Handle

// Good: Project only needed fields
import { useDocumentProjection, type DocumentHandle } from '@sanity/sdk-react'

function ArticleItem(handle: DocumentHandle) {
  const { data } = useDocumentProjection({
    ...handle,
    projection: `{
      title,
      "authorName": author->name,
      "imageUrl": image.asset->url
    }`,
  })

  if (!data) return null

  return (
    <li>
      <h2>{data.title}</h2>
      <p>By {data.authorName}</p>
    </li>
  )
}

Real-time Editing

// Good: Read and write directly to Content Lake
import { useDocument, useEditDocument, type DocumentHandle } from '@sanity/sdk-react'

function TitleInput(handle: DocumentHandle) {
  const { data: title } = useDocument({ ...handle, path: 'title' })
  const editTitle = useEditDocument({ ...handle, path: 'title' })

  return (
    <input
      type="text"
      value={title ?? ''}
      onChange={(e) => editTitle(e.currentTarget.value)}
    />
  )
}
// Bad: Local state with submit button - causes stale data
function BadTitleForm(handle: DocumentHandle) {
  const [value, setValue] = useState('')
  const editTitle = useEditDocument({ ...handle, path: 'title' })

  function handleSubmit(e: FormEvent) {
    e.preventDefault()
    editTitle(value) // Only writes on submit!
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={value} onChange={(e) => setValue(e.target.value)} />
      <button type="submit">Save</button>
    </form>
  )
}

Document Actions

import {
  useApplyDocumentActions,
  publishDocument,
  unpublishDocument,
  deleteDocument,
} from '@sanity/sdk-react'

function DocumentActions({ handle }: { handle: DocumentHandle }) {
  const apply = useApplyDocumentActions()

  return (
    <div>
      <button onClick={() => apply(publishDocument(handle))}>Publish</button>
      <button onClick={() => apply(unpublishDocument(handle))}>Unpublish</button>
      <button onClick={() => apply(deleteDocument(handle))}>Delete</button>
    </div>
  )
}

Suspense Patterns

The App SDK uses React Suspense. Every data-fetching component must be wrapped.

One Hook Per Component

// Good: Separate fetchers into separate components
function EventsAndVenues() {
  return (
    <>
      <Suspense fallback="Loading events...">
        <EventsList />
      </Suspense>
      <Suspense fallback="Loading venues...">
        <VenuesList />
      </Suspense>
    </>
  )
}

function EventsList() {
  const { data } = useDocuments({ documentType: 'event' })
  return <List items={data} />
}

function VenuesList() {
  const { data } = useDocuments({ documentType: 'venue' })
  return <List items={data} />
}
// Bad: Multiple fetchers in one component
function BadComponent() {
  const { data: events } = useDocuments({ documentType: 'event' })
  const { data: venues } = useDocuments({ documentType: 'venue' })
  // Both trigger Suspense together, causing unnecessary re-renders
}

Prevent Layout Shift

// Good: Fallback matches final component dimensions
const BUTTON_TEXT = 'Open in Studio'

export function OpenInStudio({ handle }: { handle: DocumentHandle }) {
  return (
    <Suspense fallback={<Button text={BUTTON_TEXT} disabled />}>
      <OpenInStudioButton handle={handle} />
    </Suspense>
  )
}

function OpenInStudioButton({ handle }: { handle: DocumentHandle }) {
  const { navigateToStudioDocument } = useNavigateToStudioDocument(handle)
  return <Button onClick={navigateToStudioDocument} text={BUTTON_TEXT} />
}

Event Handling

import { useDocumentEvent, DocumentEvent } from '@sanity/sdk-react'

function DocumentWatcher(handle: DocumentHandle) {
  useDocumentEvent({
    ...handle,
    onEvent: (event) => {
      switch (event.type) {
        case 'edited':
          console.log('Edited:', event.documentId)
          break
        case 'published':
          console.log('Published:', event.documentId)
          break
        case 'deleted':
          console.log('Deleted:', event.documentId)
          break
      }
    },
  })

  return null
}

Multi-Project Apps

const config: SanityConfig[] = [
  { projectId: 'project-1', dataset: 'production' },
  { projectId: 'project-2', dataset: 'staging' },
]

// Handles include project/dataset info
const handle: DocumentHandle = {
  documentId: 'doc-123',
  documentType: 'article',
  projectId: 'project-1',
  dataset: 'production',
}

Lazy Loading with Refs

function LazyContent(handle: DocumentHandle) {
  const ref = useRef(null)

  const { data } = useDocumentProjection({
    ...handle,
    ref, // Only loads when element enters viewport
    projection: '{ title, body }',
  })

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

What's NOT Included

The App SDK provides hooks and data stores. You bring:

  • UI components (use Sanity UI or your own)
  • Router
  • Form validation
  • Schema validation

Troubleshooting

Issue Solution
Safari dev issues Use Chrome or Firefox during development
Port 3333 in use npm run dev -- --port 3334
Auth errors npx sanity@latest logout && npx sanity@latest login