turbo-start-sanity
CLAUDE.md185 starsChanged 2 months agoMITView on GitHub
Turbo Start Sanity — a pnpm monorepo (Turborepo) with a Next.js 16 frontend and a Sanity Studio v6 CMS. Uses Biome/Ultracite for linting/formatting. Note: pnpm test at the root is turbo run test, which today only reaches @workspace/sanity-blocks — the one package with a test script. Run a single package's suite with pnpm --filter @workspace/sanity-blocks test. The core content model is a page builder — an array of typed blocks:
- Reads credentials
We split this CLAUDE.md into the rules it gives an agent
13 rules. About 0 are common, 1 is distinctive, and 12 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
12
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 85
Create packages/sanity-blocks/src/<new-block>/ with <new-block>.schema.ts and <new-block>.groq.ts
- Very specific: line 87
Import the block's GROQ projection in packages/sanity/src/query.ts and include it in pageBuilderFragment
- Very specific: line 88
Run pnpm --filter studio extract then pnpm type to regenerate Sanity types — both, in that order
- Very specific: line 89
Create the styled component as packages/sanity-blocks/src/<new-block>/index.tsx
- Very specific: line 92
Add packages/sanity-blocks/src/<new-block>/thumbnail.png so the block has a preview in the Studio insert menu, then run pnpm --filter …
- Very specific: line 129
Pages: use nested slug-based structure (apps/studio/components/nested-pages-structure.ts)
- Very specific: line 153
All frontend types derive from generated Sanity types.
- Very specific: line 207
Always use defineType, defineField, defineArrayMember from sanity
- Very specific: line 216
Prefer grid over flex unless two sibling elements
- Very specific: line 217
Use SanityImage component for Sanity images (from sanity-image library)
- Very specific: line 218
Use SanityButtons resolver for button arrays
- Distinctive: line 224
Import ordering: node/packages → blank line → aliases/paths
- Very specific: line 226
Use @workspace/logger Logger class instead of raw console.*
≈ “Common” is an estimate: at least 1 in 5 of the 25,680 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Turbo Start Sanity — a pnpm monorepo (Turborepo) with a Next.js 16 frontend and a Sanity Studio v6 CMS. Uses Biome/Ultracite for linting/formatting.
Commands
# Development (both apps)
pnpm dev
# Individual apps
pnpm dev:web # Next.js on localhost:3000 (Turbopack)
pnpm dev:studio # Sanity Studio on localhost:3333
# Build
pnpm build # All packages
pnpm build:web # Next.js only
pnpm build:studio # Studio only
# Lint & Format (Biome/Ultracite, NOT ESLint/Prettier)
pnpm lint # Lint all
pnpm format # Format all (auto-fix)
pnpm format:check # Check formatting without fixing
pnpm check-types # TypeScript type checking
# Per-package lint/format
cd apps/web && pnpm lint
cd apps/studio && pnpm format
# Sanity type generation (run after schema changes — both, in this order)
pnpm --filter studio extract # Refresh the committed apps/studio/schema.json
pnpm type # Generate types from schema.json (root via turbo, or apps/studio)
# Tests
pnpm test # Vitest unit tests (currently only @workspace/sanity-blocks)
pnpm test:e2e # Playwright smoke tests (apps/web, needs a running/deployed site)
pnpm --filter web test:e2e:presentation # Studio → website loop (Presentation drafts, publish, live update, no leaks); starts Studio on 3333 + `next build && next start` on 3000Note: pnpm test at the root is turbo run test, which today only reaches
@workspace/sanity-blocks — the one package with a test script. Run a single
package's suite with pnpm --filter @workspace/sanity-blocks test.
Monorepo Structure
apps/
web/ — Next.js 16 (App Router, React 19, React Compiler, Cache Components, Tailwind v4)
studio/ — Sanity Studio v6 (Vite, styled-components)
packages/
env/ — @workspace/env — Zod-validated env vars via @t3-oss/env-nextjs
sanity/ — @workspace/sanity — Shared Sanity client, GROQ queries, live preview, image utils
sanity-blocks/ — @workspace/sanity-blocks — Block schemas, GROQ projections, React block components, Markdown serializers, Vitest suite
ui/ — @workspace/ui — Shared UI components (Base UI + CVA + Tailwind, shadcn-style; only `Slot` is Radix)
tailwind-config/ — @workspace/tailwind-config — Shared Tailwind v4 theme + `cn` utility
logger/ — @workspace/logger — Structured logger class with context prefixes
typescript-config/ — Shared TS configsArchitecture
Data Flow: Sanity → Next.js
- Schema block types defined in
packages/sanity-blocks/src/and re-exported via@workspace/sanity-blocks. Studio registers them viaapps/studio/schemaTypes/index.ts - Type generation:
pnpm --filter studio extractrefreshes the committedapps/studio/schema.json, thenpnpm type(repo root via turbo, or inapps/studio) generates TS types atpackages/sanity/src/sanity.types.ts.pnpm typealone reads the oldschema.json, so a new block silently never reaches the generated types - GROQ queries live in
packages/sanity/src/query.tsusingdefineQueryfromnext-sanity, with reusable fragments - Data fetching uses
sanityFetchfrompackages/sanity/src/live.ts(wrapsdefineLivefor automatic revalidation) - Client configured in
packages/sanity/src/client.tswith stega for visual editing
Page Builder Pattern
The core content model is a page builder — an array of typed blocks:
- Schema source:
packages/sanity-blocks/src/<block>/— files are named after the block, e.g.cta/cta.schema.ts,cta/cta.groq.ts,cta/index.tsx,cta/markdown.ts,cta/thumbnail.png, plus co-located*.test.tsx/*-markdown.test.ts. All schemas are exported asblockSchemasfrompackages/sanity-blocks/src/sanity-blocks.ts - Studio side:
apps/studio/schemaTypes/index.tsmergesblockSchemasinto the exportedschemaTypes, andapps/studio/schemaTypes/definitions/pagebuilder.tsmaps overblockSchemasto build the array members — so a block added toblockSchemasshows up in the page builder automatically, no manual registration. Its insert-menu grid preview is/static/thumbnails/preview-<kebab-case-name>.png, copied from each block'sthumbnail.pngby the studio'ssync-thumbnailsscript (runs onpostinstall) - Frontend side:
apps/web/src/components/pagebuilder.tsx— renders each_typeviarenderBlockComponent. Includes Sanity visual editing data attributes and optimistic updates - Block components:
packages/sanity-blocks/src/<block>/index.tsx— styled implementations using Tailwind +@workspace/ui, imported bypagebuilder.tsxfrom@workspace/sanity-blocks/<block>/indexand rendered directly. These are the production render layer
To add a new page builder block:
- Create
packages/sanity-blocks/src/<new-block>/with<new-block>.schema.tsand<new-block>.groq.ts - Export the schema from
packages/sanity-blocks/src/sanity-blocks.tsand add it to theblockSchemasarray — Studio then picks it up automatically throughapps/studio/schemaTypes/index.tsanddefinitions/pagebuilder.ts - Import the block's GROQ projection in
packages/sanity/src/query.tsand include it inpageBuilderFragment - Run
pnpm --filter studio extractthenpnpm typeto regenerate Sanity types — both, in that order - Create the styled component as
packages/sanity-blocks/src/<new-block>/index.tsx - Register in
renderBlockComponentinapps/web/src/components/pagebuilder.tsx(imported from@workspace/sanity-blocks/<new-block>/index) - Add
packages/sanity-blocks/src/<new-block>/markdown.tsexporting a<newBlock>ToMarkdownserializer, then wire it into theblockToMarkdownswitch inpackages/sanity-blocks/src/internal/page-builder-to-markdown.ts(a thin dispatcher — the serializers themselves live with their blocks) so the block degrades to semantic Markdown. ReuseheadingToMarkdownfrominternal/markdown.tsandportableTextToMarkdownfrominternal/portable-text-to-markdown.ts, and add a test asserting no JSX leaks. Without this, the new block renders blank in.mdoutput - Add
packages/sanity-blocks/src/<new-block>/thumbnail.pngso the block has a preview in the Studio insert menu, then runpnpm --filter studio sync-thumbnails
Markdown content negotiation
Any page is also served as Markdown for LLMs/agents: append .md to the URL (/about.md, /blog/post.md, /index.md) or send Accept: text/markdown. apps/web/src/proxy.ts rewrites those requests to apps/web/src/app/api/markdown/route.ts, which fetches the page's Sanity data and serializes it via pageBuilderToMarkdown — the Markdown counterpart of renderBlockComponent. Because it serializes structured data (never React), components can't leak as raw <Component/> tags; unknown block types return "". See step 7 above to support a new block.
Video (Mux)
Video is hosted on Mux, not stored as a Sanity file asset. sanity-plugin-mux-input (registered in apps/studio/sanity.config.ts) adds the mux.video field type; use the muxVideoField() helper from packages/sanity-blocks/src/internal/schema-fields.ts (it takes optional validation and hidden) and the muxVideoFields GROQ fragment, which resolves the referenced mux.videoAsset down to { playbackId, aspectRatio, status, thumbTime, title, policy }. One upload covers every device — Mux serves an adaptive HLS ladder — so schemas carry a single video field, not a per-format set. The hero is the exception, and keeps both; see the end of this section.
Helpers live in packages/sanity-blocks/src/internal/mux.ts and every value that reaches a URL or a CSS declaration is stega-cleaned there, because Visual Editing encodes invisible characters into strings. muxPlaybackId is the only way to read a playback ID, and it withholds one when Mux reports errored or when the playback policy is anything but public (signed playback needs JWTs this starter does not mint). It deliberately does not gate on status === "ready": that field is patched by a poll running in the editor's browser tab, so it stalls at preparing whenever the tab closes mid-encode, and gating on it would hide a playable video indefinitely.
Upload settings are left on the plugin defaults (video_quality: "plus", 1080p ceiling, public playback); every upload is billed, so each project should pick its own tier in sanity.config.ts. Mux API credentials are not env vars: an editor pastes the token ID and secret into the plugin's setup screen the first time they upload, and the Studio stores them in the dataset as secrets.mux. That is a trade, not just a convenience — on a public dataset the secret key is readable by anyone who can query it, so scope the token to the minimum the plugin actually needs — Mux Video read/write and Mux Data read, per its own README — and keep the dataset private if that matters.
Two render paths, deliberately different:
- Content video —
internal/mux-video.tsxrenders a facade: the Mux still and a play button, with@mux/mux-player-reactbehind anext/dynamicimport that only resolves once a visitor presses play. Until then neither the player chunk nor the video bytes Mux bills are spent on someone who never watches.disableTrackingkeeps Mux Data off both paths — drop it and set anenvKeyto turn analytics on. The block renders its copy even when no video resolves, matching the Markdown serializer. - Hero background — the bare
@mux/mux-video-reactelement behind anext/dynamicimport, so hls.js stays off pages with no video. It autoplays, which makes it the one surface billed for Mux delivery on every page view, and itsmaxResolution/renditionOrderprops are pinned for a reason the comment beside them explains. A variant is a Mux clip, a picture, or both; leave the picture empty and the clip's own still stands in at the editor'sthumbTime.
The hero is the one block that offers a choice of delivery. Each theme variant carries a mediaType:
sanity(the default) — the hand-encoded set: an AV1.webm, an HEVC.mp4, and a smaller.webmfor phones, served straight off the asset CDN. No player, no manifest.mux— the adaptive HLS ladder, driven by hls.js.mux-mp4— the same Mux asset as one progressive MP4. No player either, but it only works where static renditions were enabled (POST /video/v1/assets/{id}/static-renditions); Mux 404s{resolution}.mp4until they are, and bills storage per rendition kept.
sanity is the default because it measured smallest and sharpest on this hero — 2.02 MB at 3408×2160 against 2.92 MB at 1704×1080 for mux-mp4 — and ships no JavaScript. Mux earns its place on blocks an editor uploads to often, not on the one clip that autoplays on every visit.
hero.groq.ts projects both paths regardless of the setting, so flipping the toggle in Presentation does not blank the background while the query re-runs, and the Studio hides whichever set is not selected.
mediaTypeOf in packages/sanity-blocks/src/hero/media-type.ts is the only way to read that choice, and isMuxPath is how callers ask "does this resolve a playback id" without caring which Mux path it is. mediaTypeOf infers rather than defaults when the field is absent, because the field arrived after the documents did — a blank value means "read what the variant actually carries", so heroes authored before the toggle keep rendering. Nothing ever infers to mux-mp4: static renditions are opt-in per asset, so that path is only reached by an explicit choice. It lives outside hero-video.tsx on purpose: that is a client module pulling in the Mux player graph, and the Markdown serializer needs the same answer without paying for hls.js.
Because next/dynamic resolves only when the adaptive element renders, a hero set to sanity or mux-mp4 never downloads the player — measured at 793 bytes of JS between them, against 648,817 bytes for the hls.js bundle. That isolation is what makes /sanity-video-delivery, /mux-video-delivery and /mux-mp4-video-delivery — three page documents cloning the homepage page builder with only mediaType flipped — a fair measurement of one delivery path against another.
Sanity Document Types
Singletons (one instance each): homePage, blogIndex, settings, footer, navbar
Documents: blog, page, faq, author, redirect
Pages use nested slug-based structure (apps/studio/components/nested-pages-structure.ts)
Environment Variables
Canonical source of truth is apps/web/.env.example and apps/studio/.env.example. Copy them to .env (or .env.local) in the same folder.
apps/web (validated by @workspace/env):
- Required:
NEXT_PUBLIC_SANITY_PROJECT_ID,NEXT_PUBLIC_SANITY_DATASET,NEXT_PUBLIC_SANITY_STUDIO_URL,SANITY_API_READ_TOKEN,SANITY_API_WRITE_TOKEN - Optional:
NEXT_PUBLIC_SANITY_API_VERSION(blank or unset falls back toDEFAULT_SANITY_API_VERSIONfrom@workspace/env/constants, which uses the current UTC date at runtime),SANITY_REVALIDATE_SECRET(shared secret for the/api/revalidate-sync-tagswebhook; the route fails closed when unset), andSANITY_CONTEXT_ENDPOINT,SANITY_CONTEXT_TOKENfor the FAQ block's ask row (/api/askreturns 503 until both are set). The ask row calls Claude through Vercel AI Gateway, which authenticates withAI_GATEWAY_API_KEYor, on Vercel and aftervercel env pull, the project'sVERCEL_OIDC_TOKEN NEXT_PUBLIC_VERCEL_ENV,NEXT_PUBLIC_VERCEL_URL,NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URLare also validated but default to localhost, so they need no local value
Caveat: SANITY_API_WRITE_TOKEN is currently required by packages/env/src/server.ts even though no runtime code reads it. apps/web/next.config.ts imports @workspace/env/server, so next dev and next build both fail fast if it is unset.
apps/studio (plain process.env, loaded via dotenv/Vite — not @workspace/env):
- Required:
SANITY_STUDIO_PROJECT_ID,SANITY_STUDIO_DATASET - Optional:
SANITY_STUDIO_TITLE,SANITY_STUDIO_API_VERSION(defaults toDEFAULT_SANITY_API_VERSIONfrom@workspace/env/constants, which uses the current UTC date at runtime, inapps/studio/utils/constant.ts),SANITY_STUDIO_APP_ID(written back after the firstsanity deploy),SANITY_STUDIO_PRESENTATION_URL(required wheneverNODE_ENVis notdevelopment—utils/helper.tsreturnshttp://localhost:3000in development and throws otherwise, so an unset ortestNODE_ENVthrows too) NEXT_PUBLIC_SITE_URLandSANITY_REVALIDATE_SECRETare read only by the deployed Sanity Functionapps/studio/functions/invalidate-tags, not by the Studio itself
Web env vars are Zod-validated at startup via @workspace/env (@workspace/env/client and @workspace/env/server).
Types Strategy
All frontend types derive from generated Sanity types. apps/web/src/types.ts extracts narrow types from query results using Extract, NonNullable, and index access — never manually duplicate Sanity shapes.
Visual Editing & Live Preview
- Sanity Presentation Tool configured in
sanity.config.tswithpresentationTool - Next.js renders
VisualEditingLayer(VisualEditingfromnext-sanity/visual-editingplus this app's overlay components) in layout (draft mode only) createDataAttributeused throughout page builder for click-to-edit in PresentationSanityLivecomponent enables automatic content revalidation
Double-click to type (custom Presentation overlay)
apps/web/src/components/overlay-components.tsx is the resolver handed to
<VisualEditing components={...} /> through visual-editing-layer.tsx (its own
client component, because a function cannot cross the server/client boundary).
It returns InlineText when isInlineEditable in inline-text.tsx allows it:
the element holds a single text node, sits outside any link, button, summary
or label (the click capture would swallow their handlers), and either
- Plain strings: carries the bare
data-inline-editflag over astringfield. Never flag a multi-linetextfield: nothing at runtime can tell the two apart, Enter saves, and a paste collapses newlines.BlockEyebrow,BlockHeader's title and each block's own plain-string titles, subtitles, captions and testimonial author lines carry it. Opt-in, because the resolver also sees every nav link, button label and badge - Rich text: has a Portable Text span path (
…children[_key=="s"].text). The words in a one-span paragraph, or in the bold/italic run inside one, can be typed over. Plain runs in a paragraph that also has marks, and marks, links and new paragraphs themselves, stay in the Studio form
inline-text.tsx makes the element contentEditable="plaintext-only" on
double-click and saves once on blur through useDocuments(), to the overlay
node's own id and path. Sanity ships nothing official for inline typing;
this is custom on that documented API. The rules that keep typing and page
updates from trampling each other (strip stega first, save only on blur, put
typed text back over a render, rewrite React's text node in place, restore on
cancel, empty or a locally rejected patch (the Studio's own write is
fire-and-forget, so a server rejection is not reported back), end without
saving if React restructures the
text mid-edit, clean up if the element is removed) live as comments in
inline-text.tsx. No real click on an editable element reaches the overlay: a
click opening the field makes the Studio focus its input, which ends an edit
mid-word. A single click is replayed after the double-click window, and Enter
opens the field with the saved value. Saving is last-write-wins, as in the
Studio form. Inline
editing is on only when the preview's perspective is drafts: LivePreviewLayer
passes that to VisualEditingLayer, because saves always write drafts.<id>,
which a published or release preview never shows. In a drafts preview, text
from the published document stays editable, because a page with no draft
renders from it and the first save creates the draft.
Visitors never mount any of it: VisualEditingLayer renders only in draft
mode, and the data-inline-edit attribute is inert outside Presentation.
Conventions
File Naming
- kebab-case for all files:
feature-cards-icon.ts,blog-card.tsx .tsxfor React components,.tsfor utilities
Sanity Schema
- Always use
defineType,defineField,defineArrayMemberfromsanity - Include
descriptionon every field (written for non-technical users) - Icons: block schemas use
lucide-react(all ten do) — stub any new one insanity-blocks/src/internal/testing/lucide-react.mock.tsx.@sanity/iconsis for Studio UI and Portable Text config - GROQ: don't expand images unless explicitly needed. Use
defineQueryfromnext-sanity
Frontend
- Prefer
gridoverflexunless two sibling elements - Use
SanityImagecomponent for Sanity images (fromsanity-imagelibrary) - Use
SanityButtonsresolver for button arrays - Shared UI components in
@workspace/ui(Base UI + CVA pattern; onlySlotis Radix)
Formatting (Biome)
- Double quotes, semicolons, trailing commas (ES5), 2-space indent, 80 char line width
- Import ordering: node/packages → blank line → aliases/paths
noConsole: warn,noExplicitAny: warn- Use
@workspace/loggerLogger class instead of rawconsole.*
Node/Runtime
- Node >= 24 required (
engines.nodein the rootpackage.json) - pnpm 11.24.0, pinned via
packageManager— enable withcorepack enable - Turborepo handles task orchestration —
transittask runs before lint/format/check-types
In this repository27 other files it gives its agents.
- .agents/skills/vercel-composition-patterns/AGENTS.mdAGENTS.md
- .agents/skills/vercel-optimize/AGENTS.mdAGENTS.md
- .agents/skills/vercel-react-best-practices/AGENTS.mdAGENTS.md
- .agents/skills/vercel-react-view-transitions/AGENTS.mdAGENTS.md
- .cursor/rules/frontend-rules.mdcCursor rule
- .cursor/rules/sanity-rules.mdcCursor rule
- .cursor/rules/ultracite.mdcCursor rule
- .cursor/rules/ultracite-migration.mdcCursor rule
- new-blockSkill
- next-best-practicesSkill
- next-cache-componentsSkill
- next-upgradeSkill
- vercel-cli-with-tokensSkill
- vercel-composition-patternsSkill
- vercel-optimizeSkill
- vercel-react-best-practicesSkill
- vercel-react-view-transitionsSkill
- web-design-guidelinesSkill
- writing-guidelinesSkill
- content-experimentation-best-practicesSkill
- content-modeling-best-practicesSkill
- generate-thumbnails-agenticSkill
- portable-text-conversionSkill
- portable-text-serializationSkill
- sanity-best-practicesSkill
- sanity-live-cache-componentsSkill
- seo-aeo-best-practicesSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

