--- title: Sanity App SDK description: 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 ```bash # Basic quickstart npx sanity@latest init --template app-quickstart --organization --output-path . --typescript --skip-mcp # With Sanity UI components npx sanity@latest init --template app-sanity-ui --organization --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 ``, 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 `` and `` boundaries --- ## Configuration ### CLI Config (`sanity.cli.ts`) ```typescript import { defineCliConfig } from 'sanity/cli' export default defineCliConfig({ app: { organizationId: 'your-org-id', entry: './src/App.tsx', }, }) ``` ### App Root (`src/App.tsx`) ```typescript import { SanityApp, type SanityConfig } from '@sanity/sdk-react' export default function App() { const config: SanityConfig[] = [ { projectId: 'your-project-id', dataset: 'production', }, ] return ( Loading...}> ) } ``` ### With Sanity UI ```typescript 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 ( Loading...}> ) } ``` ### Environment Variables Prefix with `SANITY_APP_` for automatic bundling: ```bash 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. ```typescript interface DocumentHandle { documentId: string documentType: string projectId?: string dataset?: string } ``` ### Creating Handles ```typescript // 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 ```typescript // 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 ( <>
    {data.map((handle) => ( Loading...}> ))}
{hasMore && ( )} ) } ``` ```typescript // Bad: Over-fetching with raw GROQ, no pagination function BadArticleList() { const { data } = useQuery(`*[_type == "article"]`) return data?.map((doc, i) =>
  • {doc.title}
  • ) } ``` ### Projecting Content from a Handle ```typescript // 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 (
  • {data.title}

    By {data.authorName}

  • ) } ``` ### Real-time Editing ```typescript // 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 ( editTitle(e.currentTarget.value)} /> ) } ``` ```typescript // 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 (
    setValue(e.target.value)} />
    ) } ``` ### Document Actions ```typescript import { useApplyDocumentActions, publishDocument, unpublishDocument, deleteDocument, } from '@sanity/sdk-react' function DocumentActions({ handle }: { handle: DocumentHandle }) { const apply = useApplyDocumentActions() return (
    ) } ``` --- ## Suspense Patterns The App SDK uses React Suspense. Every data-fetching component must be wrapped. ### One Hook Per Component ```typescript // Good: Separate fetchers into separate components function EventsAndVenues() { return ( <> ) } function EventsList() { const { data } = useDocuments({ documentType: 'event' }) return } function VenuesList() { const { data } = useDocuments({ documentType: 'venue' }) return } ``` ```typescript // 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 ```typescript // Good: Fallback matches final component dimensions const BUTTON_TEXT = 'Open in Studio' export function OpenInStudio({ handle }: { handle: DocumentHandle }) { return ( }> ) } function OpenInStudioButton({ handle }: { handle: DocumentHandle }) { const { navigateToStudioDocument } = useNavigateToStudioDocument(handle) return