Setup & Workspace
Setup
Requirements
Node.js 20+ (22 recommended; CI tests 20 and 22), pnpm 10 (pinned in root packageManager), a Sanity project with project id. Optional: Mux tokens for video uploads; Netlify (or any host) for deploy — netlify.toml is included.
Fork or clone
- Fork the repo on GitHub (or clone upstream), then
git cloneyour fork locally. - Pick an i18n branch before you customize:
main= field-level translations in one document;variant/document-level= separate document per language. PR #62 compares both — choose once, then build on that branch. - Mono-repo layout:
web/(Next.js),studio/(Sanity),packages/*(shared resolver, strip-readmes helper).
Install
- From repo root:
pnpm install(workspaces: web, studio, packages). - Copy env templates:
web/.env.example→web/.env.localstudio/.env.example→studio/.env
- Set
SANITY_STUDIO_PROJECT_IDin both files (same Sanity project). That is the only required variable for local dev; everything else has sensible defaults.
Datasets (recommended)
- Create a
developmentdataset in Sanity (UI or CLI) so local edits never touch live content — resolver prefersdevelopmentlocally and in preview. - Or pin
SANITY_STUDIO_DATASET=production(and optionallyNEXT_PUBLIC_SANITY_DATASETon web) to skip dev/prod splitting. - Optional:
SANITY_STUDIO_DEPLOYMENT_TARGETper deploy environment (staging,production, …). See section 9 for resolver rules.
First run
pnpm dev— Studio (http://localhost:3333) and web (http://localhost:3000) in parallel.- Or separately:
pnpm studio:dev,pnpm web:dev(web usesnext dev --webpackby default on Next 16.2). - In Studio: Settings → Site languages — publish (defaults
en+deon first create). Create apagewith a unique slug. Openhttp://localhost:3000/<slug>(default locale unprefixed) orhttp://localhost:3000/de/<slug>.
Presentation / draft (optional for day one)
- Add
SANITY_API_READ_TOKENtoweb/.env.localfor draft mode and Visual Editing. - Set
SANITY_STUDIO_PREVIEW_ORIGIN=http://localhost:3000instudio/.env(match host exactly — not mixing127.0.0.1andlocalhost).
Ship your fork
- Documentation lives in per-folder READMEs while you learn; remove them in bulk when the fork is yours:
pnpm strip-readmes(@repo/strip-readmes). - Regenerate types after schema changes:
pnpm studio:generate. - Deploy web (Netlify or other) with env from section 9; deploy Studio with
pnpm studio:deploy(sets production dataset target on deploy script). - Configure Sanity Revalidate webhook →
https://<your-site>/api/revalidatewithSANITY_REVALIDATE_SECRET.
Scripts (root)
pnpm build·pnpm lint·pnpm format·pnpm typecheck·pnpm studio:sync-prod-to-dev(manual prod → dev dataset clone).
Tooling, CI, deploy
- pnpm workspaces —
web,studio,packages/*. - Biome — root config; Studio 2-space override, rest tabs. Husky: pre-commit format, pre-push format + typecheck.
- GitHub Actions — verify (Biome + diff, TS Node 20/22), schema-typegen (extract + generate + diff), build-web, build-studio; concurrency cancel.
- Dependabot — weekly npm; Sanity plugins grouped.
- Node 20 minimum, 22 recommended; pnpm 10 pinned.
- Web dev:
pnpm web:devrunsnext dev --webpack(Turbopack optional; avoids panics on some Next 16.2 setups). - Studio deploy:
pnpm studio:deploysetsSANITY_STUDIO_DEPLOYMENT_TARGET=production. pnpm strip-readmes— bulk-remove per-folder READMEs when shipping a fork.
Key environment variables
SANITY_STUDIO_PROJECT_ID— required (both apps).SANITY_API_READ_TOKEN— draft + server draft reads (optional locally, needed for VE).SANITY_REVALIDATE_SECRET— required in production for webhook.NEXT_PUBLIC_SITE_URL— required in prod (sitemap, robots, metadata base).SANITY_STUDIO_PREVIEW_ORIGIN— Studio Presentation iframe origin.SANITY_STUDIO_DEPLOYMENT_TARGET— dataset preference / custom dataset name per deploy env.SANITY_STUDIO_DATASET/NEXT_PUBLIC_SANITY_DATASET— hard pin.- Mux —
SANITY_STUDIO_MUX_TOKEN_ID/_SECRETinstudio/.env.
Workspace Features
Dataset detection (@repo/sanity-dataset-resolve)
Both web and Studio resolve the Content Lake dataset through the same package — not NODE_ENV (local next start still prefers development when appropriate).
Resolution order:
- Explicit pin wins —
SANITY_STUDIO_DATASETorNEXT_PUBLIC_SANITY_DATASETskips all heuristics. SANITY_STUDIO_DEPLOYMENT_TARGETsets preference order:production→ tryproductionfirst, fall back todevelopment.development/preview/ unset → trydevelopmentfirst, fall back toproduction.- Custom name (e.g.
staging) → that dataset first, then the dev/prod pair in deployment order.
- Canonical names overridable via
SANITY_STUDIO_DATASET_DEVELOPMENT/_PRODUCTION(defaults:development,production). - Existence check — with project id + token (
SANITY_STUDIO_DATASET_RESOLVER_TOKENorSANITY_API_READ_TOKEN), the Management API lists datasets; otherwise HTTP probe on the Data API. Avoids requesting a dataset that does not exist. - Host inference — on Vercel (
VERCEL_ENV=production) or Netlify (CONTEXT=production), prefer production first when target is unset.
Where consumed: web/sanity/sanityEnv.ts, web/sanity/resolveStudioDataset.ts, studio/config/sync/studioDataset.ts. Image URLs use the same projectId + dataset as client.ts.
Operational notes:
- No in-UI dataset switch — dev server or deploy env picks the dataset.
- Recommended: create a
developmentdataset for local iteration; or pinSANITY_STUDIO_DATASET=productionto skip splitting. - Prod → dev sync is manual only:
pnpm studio:sync-prod-to-devexportsproductionto a tarball, imports intodevelopmentwith--replace. Never automatic (destructive for dev-only content). Dev server prints a reminder; pass--yesfor CI.
Netlify: build.ignore is dataset-aware — commits that only touch studio/ skip rebuilding the web app.
Data access & caching
Two fetch paths:
fetchSanityData.ts— wrapssanityFetchfromdefineLive(live.ts). Use on app routes that need Draft Mode, Presentation, and Stega.SanityLivemounts only when a read token is present or draft mode is active.cachedSanityQuery.ts—client.fetch+unstable_cache+ cache tags. Published-only paths: sitemap,generateStaticParams, anything without preview. Not for Presentation routes.
GROQ layout: strings under web/sanity/queries/ — snippets/, components/, pages/ — wrapped in defineQuery for sanity typegen. CI runs pnpm studio:generate and pnpm --filter web run generate with git diff --exit-code guards.
Revalidation: POST /api/revalidate — HMAC-SHA256 (Sanity signed webhooks), payload validation, document-type allow-list, in-memory rate limit, fail-closed in production without SANITY_REVALIDATE_SECRET.
Lazy loading & deferred JS
Images (MediaImage):
- Native
<img>with deterministic Sanity CDN URLs, full responsivesrcset/sizes, hotspot-awareobject-position, LQIP, hydration-safe URLs (no server/client mismatch). - Default:
loading="lazy", 0.2s fade-in on load. priorityprop (one per page, above-the-fold LCP):loading="eager",fetchpriority="high", no lazy fade-in.srcsetwidths: 480–2400px (capped by asset width); preview/card variants use smaller sets.
Mux player (MediaVideo):
@mux/mux-player-react/lazy— player bundle loads on demand; poster until play unless autoplay.- Optional immediate load for hero/LCP (
preload="auto", skip IO gate).
Background loops (MediaVideoLoop + hls.js):
hls.jsdynamically imported — no HLS bytes until needed.IntersectionObserver(400px root margin) gates attach — video loads when entering viewport.prefers-reduced-motion→ poster only, no playback.- Carousel slides can pre-buffer adjacent loops; stacked slides may use eager poster loading.
Carousel module:
next/dynamicimport ofModuleCarousel— ~15 KB client JS loaded only when a carousel module renders.
SanityLive:
- Conditional mount in layout — only when draft preview is actually useful (read token or active draft mode).
Tailwind CSS v4 & styles pipeline
CSS-first config — no legacy tailwind.config.js. PostCSS includes a custom rem() helper.
Two entry files (both from app/layout.tsx):
tokens.css— loads breakpoints, spacing, font sizes before Tailwind (Tailwind drops these if they only sit after@import "tailwindcss").globals.css— fonts → colors →@import "tailwindcss"→ breakpoints (again) →@utilitytypography → component CSS → safelist.
Token sources (variables/):
breakpoints.css— single@theme { --breakpoint-* }source; drivesxs:…wide:utilities and@media (width >= theme(--breakpoint-*))in early CSS.colors.css— semantic--color-*; dark overrides on:root[data-theme="dark"](manual toggle, notprefers-color-schemealone).sizes.css—--space-*,--content-max-width,--container-spacing, optional--measure-rt-*for rich-text line length.fontsizes.css—--font-size-*/--line-height-*with breakpoint tiers; fluid rootclamp()fromlg(1440px) in typography layer.
Tailwind folder (tailwind/):
theme.css—@themeextensions (fonts, colors, spacing, radii);@custom-variant darkaligned with[data-theme="dark"]on<html>.safelist.css—@source inline("…")for runtime/CMS-built class names.
Custom variants in breakpoints.css: portrait, landscape, touch-coarse, fine-pointer, tall, short-viewport.
Rule: change a token in variables/*.css and the matching key in tailwind/theme.css when exposing it as a utility. Keep design tokens in variables/; Tailwind folder only for @theme, @source, rare @layer.
i18n & routing (web)
Source of truth: Sanity singleton siteLanguageSettings (Settings → Site languages) — availableLanguages (order = translation fallback order), defaultLanguageId (unprefixed URLs).
proxy.ts— CDN-backed locale list; rewrites/about→/{defaultLocale}/aboutinternally; redirects/{defaultLocale}/aboutto canonical unprefixed URL.LanguageContext— client links vialocalePath.- Reserved segments: every non-default language
idis a first path segment (/de/…) — do not reuse as page slugs on the default locale site. - Fallback when singleton missing/invalid: web uses minimal
enonly (fallbackSiteLocales.ts); Studio plugin uses matchingFALLBACK_LANGUAGES— keep both in sync. - Field resolution:
parseLocalizedText,pickLocalizedString,pickLocalizedPortableTextBlocksinsanity/utils/sanityLocalizedText.ts; optionalsiteLocalefromfetchSiteLanguageSettings()for fallback order.
Two repo branches for i18n strategy: main = field-level (internationalized-array); variant/document-level = one document per language. PR #62 compares both.
Sanity Presentation & Visual Editing (checklist)
SANITY_API_READ_TOKENinweb/.env.local— required for draft enable andsanityFetch; without it,/api/draft-mode/enablereturns 401.SANITY_STUDIO_PREVIEW_ORIGIN— exact Next origin in Presentation iframe (http://localhost:3000vs127.0.0.1— pick one).NEXT_PUBLIC_SANITY_STUDIO_URL— Studio URL for Stega overlays.SANITY_STUDIO_WEB_PREVIEW_ORIGINSin Studio.envwhen preview target is not localhost.- Hosted HTTPS Studio cannot iframe
http://localhost(mixed content) — use a tunnel and matching HTTPS origins.
SEO, security, cookies
sitemap.ts— per-localealternates+x-default;robots.tsstaging-aware.resolveSanityMetadata— canonical +hreflangper route; settings fallbacks.netlify.toml— CSP withframe-ancestorsforsanity.studio, HSTS preload, Permissions-Policy, Referrer-Policy, X-Content-Type-Options; long cache for/_next/static/*.- Cookie banner — optional via Settings;
vanilla-cookieconsent; copy/categories from Sanity JSON;hasConsent(category),open-cookie-preferencesin nav; themed with same--color-*tokens.