--- description: globs: alwaysApply: true --- --- description: Opinionated guidance for configuring Sanity Studio and authoring content with Schema UI Starter globs: \*_/_.{ts,tsx,js,jsx} alwaysApply: false --- ## Positive affirmation You are a principal-level TypeScript and React engineer who writes best-practice, high performance code. ## Schema UI Architecture ### Component Structure - The Schema UI Starter uses a composable block-based architecture - Each block is defined as a schema type in Sanity and has a corresponding React component - Blocks are organized in a hierarchical structure with parent blocks (like `split-row` or `grid-row`) containing child components ### Dynamic Component Mapping - Each parent block component uses a `componentMap` object to map schema types to React components - This pattern allows for type-safe rendering of dynamic content from Sanity ```ts // Example component map pattern const componentMap: { [K in ChildType["_type"]]: React.ComponentType< Extract >; } = { "child-type-1": ChildComponent1, "child-type-2": ChildComponent2, }; ``` ## Sanity Studio Schema Types ### Content modelling - Unless explicitly modelling web pages or app views, create content models for what things are, not what they look like in a front-end - For example, consider the `status` of an element instead of its `color` - Organize blocks into logical groups (hero, grid, split, carousel, etc.) for better content management ### Basic schema types - ALWAYS use the `defineType`, `defineField`, and `defineArrayMember` helper functions - ALWAYS write schema types to their own files and export a named `const` that matches the filename - ALWAYS include an appropriate icon from `lucide-react` for each schema type - ONLY use a `name` attribute in fields unless the `title` needs to be something other than a title-case version of the `name` - ANY `string` field type with an `options.list` array with fewer than 5 options must use `options.layout: "radio"` - ANY `image` field must include `options.hotspot: true` - INCLUDE brief, useful `description` values if the intention of a field is not obvious - INCLUDE `rule.warning()` for fields that would benefit from being a certain length - INCLUDE brief, useful validation errors in `rule.required().error('')` that signal why the field must be correct before publishing is allowed - AVOID `boolean` fields, write a `string` field with an `options.list` configuration - NEVER write single `reference` type fields, always write an `array` of references - CONSIDER the order of fields, from most important and relevant first, to least often used last ```ts // ./sanity/schemas/blocks/grid/grid-row.ts import { defineField, defineType } from "sanity"; import { LayoutGrid } from "lucide-react"; import { COLS_VARIANTS } from "../shared/layout-variants"; export default defineType({ name: "grid-row", title: "Grid Row", type: "object", icon: LayoutGrid, fields: [ defineField({ name: "padding", type: "section-padding", }), defineField({ name: "colorVariant", type: "color-variant", title: "Color Variant", description: "Select a background color variant", }), defineField({ name: "gridColumns", type: "string", title: "Grid Columns", options: { list: COLS_VARIANTS.map(({ title, value }) => ({ title, value })), layout: "radio", }, initialValue: "grid-cols-3", }), defineField({ name: "columns", type: "array", of: [ { type: "grid-card" }, { type: "grid-post" }, { type: "pricing-card" }, ], options: { insertMenu: { views: [ { name: "grid", previewImageUrl: (block) => `/sanity/preview/${block}.jpg`, }, { name: "list" }, ], }, }, }), ], preview: { select: { title: "columns.0.title", postTitle: "columns.0.post.title", }, prepare({ title, postTitle }) { return { title: "Grid Row", subtitle: title || postTitle, }; }, }, }); ``` ### Parent-Child Block Structure - Parent blocks (like `split-row` or `grid-row`) should contain an array field for child components - Child components should be defined as separate schema types - Use `validation: (rule) => rule.max(n)` to limit the number of child components when appropriate - Use `options.insertMenu` to organize child components into logical groups with preview images ```ts // Example parent block with child components defineField({ name: "splitColumns", type: "array", of: [ { type: "split-content" }, { type: "split-cards-list" }, { type: "split-image" }, { type: "split-info-list" }, ], validation: (rule) => rule.max(2), options: { insertMenu: { views: [ { name: "grid", previewImageUrl: (block) => `/sanity/preview/${block}.jpg`, }, { name: "list" }, ], }, }, }); ``` ### React Component Implementation - Create a corresponding React component for each schema type - Use TypeScript to ensure type safety between schema and component - Extract types from the Sanity query results - Implement a `componentMap` to dynamically render child components - Use `stegaClean` from `next-sanity` to clean values from Sanity ```tsx // Example React component for a parent block // components/blocks/parent-component.tsx import { stegaClean } from "next-sanity"; import { PAGE_QUERY_RESULT } from "@/sanity.types"; import SectionContainer from "@/components/blocks/section-container"; import ChildComponent1 from "./child-component-1"; import ChildComponent2 from "./child-component-2"; type Block = NonNullable["blocks"]>[number]; type ParentBlock = Extract; type ChildBlock = NonNullable[number]>; const componentMap: { [K in ChildBlock["_type"]]: React.ComponentType< Extract >; } = { "child-type-1": ChildComponent1, "child-type-2": ChildComponent2, }; export default function ParentBlock({ padding, colorVariant, children, }: ParentBlock) { const color = stegaClean(colorVariant); return ( {children && children?.length > 0 && (
{children.map((child) => { const Component = componentMap[child._type]; if (!Component) { console.warn( `No component implemented for child type: ${child._type}`, ); return
; } return ( ); })}
)} ); } ``` ### No anonymous reusable schema types - ANY schema type that benefits from being reused in multiple document types should be registered as its own custom schema type and located in shared folder. ```ts // ./sanity/schemas/blocks/shared/block-content.ts import { defineField, defineType } from "sanity"; export default defineType({ title: "Block content", name: "block-content", type: "array", of: [defineArrayMember({ title: "Block", type: "block" })], }); ``` ### Decorating schema types Every `document` and `object` schema type should: - Have an `icon` property from `lucide-react` - Have a customized `preview` property that shows rich contextual details about the document - Use `groups` when the schema type has more than a few fields to collate related fields and only show the most important group by default. These `groups` should use the icon property as well. - Use `fieldsets` with `options: {columns: 2}` if related fields could be grouped visually together, such as `startDate` and `endDate` ## Writing Sanity content for importing When asked to write content: - ONLY use the existing schema types registered in the Studio configuration - ALWAYS write content as an `.ndjson` file at the root of the project - NEVER write a script to write the file, just write the file - IMPORT `.ndjson` files using the CLI command `npx sanity dataset import ` - NEVER include a `.` in the `_id` field of a document unless you need it to be private - NEVER include image references because you don't know what image documents exist - ALWAYS write images in this format below, replacing the document ID value to generate the same placeholder image ```JSON {"_type":"image","_sanityAsset":"image@https://picsum.photos/seed/[[REPLACE_WITH_DOCUMENT_ID]]/1920/1080"} ``` ## Writing GROQ queries - ALWAYS use SCREAMING_SNAKE_CASE for variable names, for example POSTS_QUERY - ALWAYS write queries to their own variables, never as a parameter in a function - ALWAYS import the `groq` function from `next-sanity` or `sanity` to tag query strings - ALWAYS write every required attribute in a projection when writing a query - ALWAYS put each segment of a filter, and each attribute on its own line - ALWAYS use parameters for variables in a query - NEVER insert dynamic values using string interpolation - USE modular query composition by creating separate query fragments for each block type - ANNOTATE query fragments with `@sanity-typegen-ignore` when needed to prevent type generation issues - ORGANIZE query files to mirror the schema structure (e.g., queries/hero/hero-1.ts matches schemas/blocks/hero/hero-1.ts) - INCLUDE asset metadata in image queries to enable responsive images and optimizations ### Query Composition Pattern For parent-child block structures, create separate query fragments for each component type: ```ts // ./sanity/queries/split/split-row.ts import { groq } from "next-sanity"; import { splitContentQuery } from "./split-content"; import { splitCardsListQuery } from "./split-cards-list"; import { splitImageQuery } from "./split-image"; import { splitInfoListQuery } from "./split-info-list"; // @sanity-typegen-ignore export const splitRowQuery = groq` _type == "split-row" => { _type, _key, padding, colorVariant, noGap, splitColumns[]{ ${splitContentQuery}, ${splitCardsListQuery}, ${splitImageQuery}, ${splitInfoListQuery}, }, } `; ``` ### Main Page Query Pattern Compose the main page query from individual block query fragments: ```ts // ./sanity/queries/page.ts import { groq } from "next-sanity"; import { hero1Query } from "./hero/hero-1"; import { hero2Query } from "./hero/hero-2"; import { splitRowQuery } from "./split/split-row"; // ... other imports export const PAGE_QUERY = groq` *[_type == "page" && slug.current == $slug][0]{ blocks[]{ ${hero1Query}, ${hero2Query}, ${splitRowQuery}, // ... other block queries }, meta_title, meta_description, // ... other page fields } `; ``` ### Image Query Pattern For consistent image handling, always include asset metadata: ```ts // Image query pattern image{ ..., asset->{ _id, url, mimeType, metadata { lqip, dimensions { width, height } } }, alt } ``` ### Document Query Pattern For document queries, follow this pattern: ```ts // ./sanity/queries/post.ts import { groq } from "next-sanity"; export const POST_QUERY = groq`*[_type == "post" && slug.current == $slug][0]{ title, slug, // ... other fields }`; export const POSTS_QUERY = groq`*[_type == "post" && defined(slug)] | order(_createdAt desc){ // ... fields for list view }`; export const POSTS_SLUGS_QUERY = groq`*[_type == "post" && defined(slug)]{slug}`; ``` ## TypeScript - ONLY write Types for responses if you cannot generate them with Sanity TypeGen - ALWAYS export a schema extraction first from inside of the Studio code-base with `npx sanity@latest typegen generate` - ALWAYS move that schema extraction file to the root of a front-end project before running `npx sanity@latest typegen generate`