From 4636e0ac6dd8e7881a6f9d664eb84641d92553ef Mon Sep 17 00:00:00 2001 From: Charles Brown Date: Fri, 12 Dec 2025 07:43:05 -0600 Subject: [PATCH 1/3] feat: create app sdk rules --- AGENTS.md | 1 + rules/sanity-app-sdk.mdc | 579 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 580 insertions(+) create mode 100644 rules/sanity-app-sdk.mdc diff --git a/AGENTS.md b/AGENTS.md index bf461df..f3e0df3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,7 @@ If the Sanity MCP server (`https://mcp.sanity.io`) is available, use `list_sanit | **Shopify/Hydrogen** | `shopify`, `hydrogen`, `e-commerce`, `storefront`, `sanity connect` | `rules/sanity-hydrogen.mdc` | | **GROQ** | `groq`, `query`, `defineQuery`, `projection`, `filter`, `order` | `rules/sanity-groq.mdc` | | **TypeGen** | `typegen`, `typescript`, `types`, `infer`, `satisfies`, `type generation` | `rules/sanity-typegen.mdc` | +| **App SDK** | `app sdk`, `custom app`, `useDocuments`, `useDocument`, `DocumentHandle`, `SanityApp`, `sdk-react` | `rules/sanity-app-sdk.mdc` | ### Using the Knowledge Router diff --git a/rules/sanity-app-sdk.mdc b/rules/sanity-app-sdk.mdc new file mode 100644 index 0000000..052ae71 --- /dev/null +++ b/rules/sanity-app-sdk.mdc @@ -0,0 +1,579 @@ +--- +description: Rules for building custom applications with the Sanity App SDK, including React hooks, document handles, real-time patterns, and Suspense best practices. +globs: src/**/*.tsx, src/**/*.ts, sanity.cli.ts, App.tsx +alwaysApply: false +--- + +# Sanity App SDK Best Practices + +## 1. What is the App SDK? + +The Sanity App SDK is a toolkit for building **custom React applications** that interact with Sanity content. Unlike Sanity Studio, SDK apps can: +- Work across **multiple projects and datasets** +- Provide **complete UI freedom** (no structured interface) +- Build **custom workflows** tailored to specific needs +- Enable **real-time, multiplayer** content operations + +### Key Differences from Studio +| Feature | Sanity Studio | App SDK | +|---------|---------------|---------| +| Projects/Datasets | Single | Multiple | +| UI | Structured | Custom | +| Validation | Built-in | Bring your own | +| Form building | Built-in | Bring your own | + +## 2. Project Setup + +### Initialize a New App + +```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 +``` + +### CLI Configuration (`sanity.cli.ts`) + +```typescript +import { defineCliConfig } from 'sanity/cli' + +export default defineCliConfig({ + app: { + organizationId: 'your-org-id', + entry: './src/App.tsx', + }, + deployment: { + appId: 'your-app-id', // Added after first deploy + }, +}) +``` + +### App Configuration (`src/App.tsx`) + +```typescript +import { SanityApp, type SanityConfig } from '@sanity/sdk-react' + +export default function App() { + // Apps can connect to multiple projects/datasets + const config: SanityConfig[] = [ + { + projectId: 'your-project-id', + dataset: 'production', + }, + // Add more projects as needed + ] + + return ( + Loading...}> + {/* Your components here */} + + ) +} +``` + +### Environment Variables + +Use the `SANITY_APP_` prefix for environment variables: + +```bash +SANITY_APP_PROJECT_ID=abc123 +SANITY_APP_DATASET=production +``` + +Access in code: `process.env.SANITY_APP_PROJECT_ID` + +## 3. Document Handles + +Document Handles are **lightweight references** to documents, containing only metadata needed to identify them. + +```typescript +interface DocumentHandle { + documentId: string + documentType: string + projectId?: string // Optional - for multi-project apps + dataset?: string // Optional - for multi-dataset apps +} +``` + +### Why Use Document Handles? +- **Performance**: Fetch handles first, then load content as needed +- **Flexibility**: Pass to specialized hooks for specific data +- **Stable Keys**: Use `documentId` as React `key` for lists + +### Creating Handles + +```typescript +// Option 1: From useDocuments hook (preferred) +const { data: handles } = useDocuments({ documentType: 'article' }) + +// Option 2: Manual creation with helper (best for TypeGen) +import { createDocumentHandle } from '@sanity/sdk' + +const handle = createDocumentHandle({ + documentId: 'my-doc-id', + documentType: 'article', +}) + +// Option 3: Manual with `as const` (for TypeGen) +const handle = { + documentId: 'my-doc-id', + documentType: 'article', +} as const +``` + +## 4. Data Fetching Patterns + +### Core Philosophy: Prefer Handles Over Raw Queries + +```typescript +// ❌ Avoid: Over-fetching with raw GROQ +const { data } = useQuery(`*[_type == "article"]`) + +// ✅ Preferred: Fetch handles, then project content +const { data: articles } = useDocuments({ documentType: 'article' }) +``` + +### Hook Selection Guide + +| Hook | Use Case | Returns | +|------|----------|---------| +| `useDocuments` | List of documents (infinite scroll) | Document handles | +| `usePaginatedDocuments` | Paginated lists | Document handles | +| `useDocument` | Single document, real-time editing | Full document or field | +| `useDocumentProjection` | Specific fields, display only | Projected data | +| `useQuery` | Complex GROQ queries | Raw query results | + +### Fetching Document Lists + +```typescript +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 && ( + + )} + + ) +} +``` + +### Projecting Content from Handles + +```typescript +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}

    +
  • + ) +} +``` + +### Reading Single Documents (Real-time) + +```typescript +import { useDocument, type DocumentHandle } from '@sanity/sdk-react' + +function ArticleEditor(handle: DocumentHandle) { + // Full document + const { data: article } = useDocument(handle) + + // Specific field path + const { data: title } = useDocument({ + ...handle, + path: 'title', + }) + + return
    {title}
    +} +``` + +## 5. Document Editing + +### Real-time Editing with `useEditDocument` + +**Always read from and write to Content Lake** - never use local state for form values. + +```typescript +// ❌ Wrong: Local state with submit +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 - causes stale data! + } + + return ( +
    + setValue(e.target.value)} /> + +
    + ) +} + +// ✅ Correct: Read and write directly to Content Lake +function GoodTitleInput(handle: DocumentHandle) { + const { data: title } = useDocument({ ...handle, path: 'title' }) + const editTitle = useEditDocument({ ...handle, path: 'title' }) + + return ( + editTitle(e.currentTarget.value)} + /> + ) +} +``` + +### Using Functional Updates + +```typescript +import { useEditDocument, type DocumentHandle } from '@sanity/sdk-react' + +function ArticleEditor(handle: DocumentHandle) { + const editArticle = useEditDocument(handle) + + const handleTitleChange = useCallback( + (e: React.ChangeEvent) => { + editArticle((prev) => ({ + ...prev, + title: e.target.value, + })) + }, + [editArticle] + ) + + return +} +``` + +### Document Actions (Publish, Delete, etc.) + +```typescript +import { + useApplyDocumentActions, + publishDocument, + unpublishDocument, + deleteDocument, + discardDraftDocument, +} from '@sanity/sdk-react' + +function DocumentActions({ handle }: { handle: DocumentHandle }) { + const apply = useApplyDocumentActions() + + return ( +
    + + + +
    + ) +} +``` + +## 6. Suspense Patterns + +The App SDK uses **React Suspense** for data fetching. Master these patterns: + +### Wrap Every Data-Fetching Component + +```typescript +// ✅ Correct: Suspense around the component using the hook +function ParentComponent() { + return ( + }> + + + ) +} +``` + +### One Suspenseful Hook Per Component + +```typescript +// ❌ Wrong: Multiple fetchers cause unnecessary re-renders +function BadComponent() { + const { data: events } = useDocuments({ documentType: 'event' }) + const { data: venues } = useDocuments({ documentType: 'venue' }) + // Both will trigger Suspense together +} + +// ✅ Correct: Separate into individual components +function EventsAndVenues() { + return ( + <> + + + + + + + + ) +} + +function EventsList() { + const { data } = useDocuments({ documentType: 'event' }) + return +} + +function VenuesList() { + const { data } = useDocuments({ documentType: 'venue' }) + return +} +``` + +### Prevent Layout Shift with Skeleton Fallbacks + +```typescript +const BUTTON_TEXT = 'Open in Studio' + +export function OpenInStudio({ handle }: { handle: DocumentHandle }) { + return ( + }> + + + ) +} + +// Fallback matches final component dimensions +function OpenInStudioFallback() { + return )} @@ -176,9 +214,18 @@ function ArticleList() { } ``` -### Projecting Content from Handles +```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) { @@ -202,40 +249,35 @@ function ArticleItem(handle: DocumentHandle) { } ``` -### Reading Single Documents (Real-time) +### Real-time Editing ```typescript -import { useDocument, type DocumentHandle } from '@sanity/sdk-react' - -function ArticleEditor(handle: DocumentHandle) { - // Full document - const { data: article } = useDocument(handle) +// ✅ Good: Read and write directly to Content Lake +import { useDocument, useEditDocument, type DocumentHandle } from '@sanity/sdk-react' - // Specific field path - const { data: title } = useDocument({ - ...handle, - path: 'title', - }) +function TitleInput(handle: DocumentHandle) { + const { data: title } = useDocument({ ...handle, path: 'title' }) + const editTitle = useEditDocument({ ...handle, path: 'title' }) - return
    {title}
    + return ( + editTitle(e.currentTarget.value)} + /> + ) } ``` -## 5. Document Editing - -### Real-time Editing with `useEditDocument` - -**Always read from and write to Content Lake** - never use local state for form values. - ```typescript -// ❌ Wrong: Local state with submit +// ❌ 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 - causes stale data! + editTitle(value) // Only writes on submit! } return ( @@ -245,45 +287,9 @@ function BadTitleForm(handle: DocumentHandle) { ) } - -// ✅ Correct: Read and write directly to Content Lake -function GoodTitleInput(handle: DocumentHandle) { - const { data: title } = useDocument({ ...handle, path: 'title' }) - const editTitle = useEditDocument({ ...handle, path: 'title' }) - - return ( - editTitle(e.currentTarget.value)} - /> - ) -} ``` -### Using Functional Updates - -```typescript -import { useEditDocument, type DocumentHandle } from '@sanity/sdk-react' - -function ArticleEditor(handle: DocumentHandle) { - const editArticle = useEditDocument(handle) - - const handleTitleChange = useCallback( - (e: React.ChangeEvent) => { - editArticle((prev) => ({ - ...prev, - title: e.target.value, - })) - }, - [editArticle] - ) - - return -} -``` - -### Document Actions (Publish, Delete, etc.) +### Document Actions ```typescript import { @@ -291,7 +297,6 @@ import { publishDocument, unpublishDocument, deleteDocument, - discardDraftDocument, } from '@sanity/sdk-react' function DocumentActions({ handle }: { handle: DocumentHandle }) { @@ -299,48 +304,24 @@ function DocumentActions({ handle }: { handle: DocumentHandle }) { return (
    - - - + + +
    ) } ``` -## 6. Suspense Patterns +--- -The App SDK uses **React Suspense** for data fetching. Master these patterns: +## Suspense Patterns -### Wrap Every Data-Fetching Component +The App SDK uses React Suspense. Every data-fetching component must be wrapped. -```typescript -// ✅ Correct: Suspense around the component using the hook -function ParentComponent() { - return ( - }> - - - ) -} -``` - -### One Suspenseful Hook Per Component +### One Hook Per Component ```typescript -// ❌ Wrong: Multiple fetchers cause unnecessary re-renders -function BadComponent() { - const { data: events } = useDocuments({ documentType: 'event' }) - const { data: venues } = useDocuments({ documentType: 'venue' }) - // Both will trigger Suspense together -} - -// ✅ Correct: Separate into individual components +// ✅ Good: Separate fetchers into separate components function EventsAndVenues() { return ( <> @@ -365,149 +346,67 @@ function VenuesList() { } ``` -### Prevent Layout Shift with Skeleton Fallbacks +```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 ( - }> + }> ) } -// Fallback matches final component dimensions -function OpenInStudioFallback() { - return