agentleFS
robotostudiorobotostudio/turbo-start-sanity/CLAUDE.md

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
  1. Very specific:

    Create packages/sanity-blocks/src/<new-block>/ with <new-block>.schema.ts and <new-block>.groq.ts

    line 85
  2. Very specific:

    Import the block's GROQ projection in packages/sanity/src/query.ts and include it in pageBuilderFragment

    line 87
  3. Very specific:

    Run pnpm --filter studio extract then pnpm type to regenerate Sanity types — both, in that order

    line 88
  4. Very specific:

    Create the styled component as packages/sanity-blocks/src/<new-block>/index.tsx

    line 89
  5. Very specific:

    Add packages/sanity-blocks/src/<new-block>/thumbnail.png so the block has a preview in the Studio insert menu, then run pnpm --filter …

    line 92
  6. Very specific:

    Pages: use nested slug-based structure (apps/studio/components/nested-pages-structure.ts)

    line 129
  7. Very specific:

    All frontend types derive from generated Sanity types.

    line 153
  8. Very specific:

    Always use defineType, defineField, defineArrayMember from sanity

    line 207
  9. Very specific:

    Prefer grid over flex unless two sibling elements

    line 216
  10. Very specific:

    Use SanityImage component for Sanity images (from sanity-image library)

    line 217
  11. Very specific:

    Use SanityButtons resolver for button arrays

    line 218
  12. Distinctive:

    Import ordering: node/packages → blank line → aliases/paths

    line 224
  13. Very specific:

    Use @workspace/logger Logger class instead of raw console.*

    line 226

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

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 3000

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.

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 configs

Architecture

Data Flow: Sanity → Next.js

  1. Schema block types defined in packages/sanity-blocks/src/ and re-exported via @workspace/sanity-blocks. Studio registers them via apps/studio/schemaTypes/index.ts
  2. Type generation: pnpm --filter studio extract refreshes the committed apps/studio/schema.json, then pnpm type (repo root via turbo, or in apps/studio) generates TS types at packages/sanity/src/sanity.types.ts. pnpm type alone reads the old schema.json, so a new block silently never reaches the generated types
  3. GROQ queries live in packages/sanity/src/query.ts using defineQuery from next-sanity, with reusable fragments
  4. Data fetching uses sanityFetch from packages/sanity/src/live.ts (wraps defineLive for automatic revalidation)
  5. Client configured in packages/sanity/src/client.ts with 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 as blockSchemas from packages/sanity-blocks/src/sanity-blocks.ts
  • Studio side: apps/studio/schemaTypes/index.ts merges blockSchemas into the exported schemaTypes, and apps/studio/schemaTypes/definitions/pagebuilder.ts maps over blockSchemas to build the array members — so a block added to blockSchemas shows 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's thumbnail.png by the studio's sync-thumbnails script (runs on postinstall)
  • Frontend side: apps/web/src/components/pagebuilder.tsx — renders each _type via renderBlockComponent. 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 by pagebuilder.tsx from @workspace/sanity-blocks/<block>/index and rendered directly. These are the production render layer

To add a new page builder block:

  1. Create packages/sanity-blocks/src/<new-block>/ with <new-block>.schema.ts and <new-block>.groq.ts
  2. Export the schema from packages/sanity-blocks/src/sanity-blocks.ts and add it to the blockSchemas array — Studio then picks it up automatically through apps/studio/schemaTypes/index.ts and definitions/pagebuilder.ts
  3. Import the block's GROQ projection in packages/sanity/src/query.ts and include it in pageBuilderFragment
  4. Run pnpm --filter studio extract then pnpm type to regenerate Sanity types — both, in that order
  5. Create the styled component as packages/sanity-blocks/src/<new-block>/index.tsx
  6. Register in renderBlockComponent in apps/web/src/components/pagebuilder.tsx (imported from @workspace/sanity-blocks/<new-block>/index)
  7. Add packages/sanity-blocks/src/<new-block>/markdown.ts exporting a <newBlock>ToMarkdown serializer, then wire it into the blockToMarkdown switch in packages/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. Reuse headingToMarkdown from internal/markdown.ts and portableTextToMarkdown from internal/portable-text-to-markdown.ts, and add a test asserting no JSX leaks. Without this, the new block renders blank in .md output
  8. Add packages/sanity-blocks/src/<new-block>/thumbnail.png so the block has a preview in the Studio insert menu, then run pnpm --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.tsx renders a facade: the Mux still and a play button, with @mux/mux-player-react behind a next/dynamic import 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. disableTracking keeps Mux Data off both paths — drop it and set an envKey to turn analytics on. The block renders its copy even when no video resolves, matching the Markdown serializer.
  • Hero background — the bare @mux/mux-video-react element behind a next/dynamic import, 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 its maxResolution / renditionOrder props 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's thumbTime.

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 .webm for 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}.mp4 until 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 to DEFAULT_SANITY_API_VERSION from @workspace/env/constants, which uses the current UTC date at runtime), SANITY_REVALIDATE_SECRET (shared secret for the /api/revalidate-sync-tags webhook; the route fails closed when unset), and SANITY_CONTEXT_ENDPOINT, SANITY_CONTEXT_TOKEN for the FAQ block's ask row (/api/ask returns 503 until both are set). The ask row calls Claude through Vercel AI Gateway, which authenticates with AI_GATEWAY_API_KEY or, on Vercel and after vercel env pull, the project's VERCEL_OIDC_TOKEN
  • NEXT_PUBLIC_VERCEL_ENV, NEXT_PUBLIC_VERCEL_URL, NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL are 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 to DEFAULT_SANITY_API_VERSION from @workspace/env/constants, which uses the current UTC date at runtime, in apps/studio/utils/constant.ts), SANITY_STUDIO_APP_ID (written back after the first sanity deploy), SANITY_STUDIO_PRESENTATION_URL (required whenever NODE_ENV is not development — utils/helper.ts returns http://localhost:3000 in development and throws otherwise, so an unset or test NODE_ENV throws too)
  • NEXT_PUBLIC_SITE_URL and SANITY_REVALIDATE_SECRET are read only by the deployed Sanity Function apps/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.ts with presentationTool
  • Next.js renders VisualEditingLayer (VisualEditing from next-sanity/visual-editing plus this app's overlay components) in layout (draft mode only)
  • createDataAttribute used throughout page builder for click-to-edit in Presentation
  • SanityLive component 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-edit flag over a string field. Never flag a multi-line text field: 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
  • .tsx for React components, .ts for utilities

Sanity Schema

  • Always use defineType, defineField, defineArrayMember from sanity
  • Include description on every field (written for non-technical users)
  • Icons: block schemas use lucide-react (all ten do) — stub any new one in sanity-blocks/src/internal/testing/lucide-react.mock.tsx. @sanity/icons is for Studio UI and Portable Text config
  • GROQ: don't expand images unless explicitly needed. Use defineQuery from next-sanity

Frontend

  • Prefer grid over flex unless two sibling elements
  • Use SanityImage component for Sanity images (from sanity-image library)
  • Use SanityButtons resolver for button arrays
  • Shared UI components in @workspace/ui (Base UI + CVA pattern; only Slot is 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/logger Logger class instead of raw console.*

Node/Runtime

  • Node >= 24 required (engines.node in the root package.json)
  • pnpm 11.24.0, pinned via packageManager — enable with corepack enable
  • Turborepo handles task orchestration — transit task runs before lint/format/check-types

Download now (ZIP)

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.