A production-shaped starting point for a multi-language, CMS-driven site — Next.js 16 App Router, React 19, Sanity Studio v5, with optional Mux video and Netlify hosting.
It stays slim on purpose: no unused features, production-grade defaults, and a clear path from git clone to first published page. Documentation lives next to the code that needs it — strip the per-folder READMEs with pnpm strip-readmes once you're ready to ship.
This starter ships in two parallel variants so you can pick the internationalisation model that matches your editorial workflow:
| Branch | i18n strategy | Pick when |
|---|---|---|
main |
Field-level via sanity-plugin-internationalized-array |
All translations of one piece of content live in one document with language tabs per field; short, UI-shaped translations. |
variant/document-level |
Document-level via @sanity/document-internationalization |
Each language is its own document — different slugs, modules, SEO per locale. GROQ does the locale matching; no runtime resolver. |
PR #62 keeps a permanent side-by-side comparison + decision guide for adopters. Neither branch is a downgrade of the other; both are production-shaped.
Next.js app — App Router with locale-aware routing ([locale]/[slug]), generateStaticParams, ISR-style revalidation via cache tags, fully wired Sanity Draft Mode + Visual Editing (SanityLive, Presentation, stega). SanityLive only mounts when it's actually useful (read token present or draft mode active).
Sanity Studio v5 — Vision, Dashboard, Presentation with configurable preview origin, runtime language tabs driven by a siteLanguageSettings singleton, deploy-on-publish via Netlify plugin, safe production → development dataset clone.
Media pipeline — Native <img> with deterministic Sanity CDN URLs, full responsive srcset/sizes, hotspot-aware object-position, LQIP and zero hydration drift. Mux video via the official <MuxPlayer /> imported /lazy, plus a lightweight hls.js background-loop player that only loads when the element enters the viewport and respects prefers-reduced-motion.
Hardened revalidation — POST /api/revalidate does HMAC-SHA256 signature verification (Sanity signed webhooks), payload validation, a document-type allow-list, in-memory rate limiting, and is fail-closed in production.
SEO out of the box — sitemap.ts with per-locale alternates and x-default, staging-aware robots.ts, resolveSanityMetadata builds canonical + hreflang metadata for every route.
Hardened by default — CSP with frame-ancestors (Studio-friendly), HSTS preload, Permissions-Policy, Referrer-Policy, X-Content-Type-Options via netlify.toml. Netlify build.ignore skips web builds on Studio-only commits.
Cookie consent (optional) — Sanity-driven vanilla-cookieconsent banner with light/dark theming via --color-* tokens, editable copy and per-category sections in JSON, plus en / de defaults when the doc is empty. An open-cookie-preferences link function lets any nav item reopen the preferences modal, and hasConsent(category) is exposed for gating analytics scripts. Toggle it on under Settings → Cookie Banner; the banner stays inert until useCookieBanner is true.
Typed GROQ pipeline — sanity typegen generates result types straight from the schema, gated in CI by a git diff --exit-code after every regeneration. (Hybrid coverage on main; full coverage on the variant/document-level branch — no recursive fragment to block the analyser.)
Tailwind CSS v4 — CSS-first config (@theme), token-driven dark mode without class hacks, PostCSS pipeline with custom rem() helper. Design tokens live in web/src/assets/styles/.
Managed with pnpm workspaces (pnpm-workspace.yaml: web, studio, packages/*).
| Package | Path | Role |
|---|---|---|
| Web | web/ |
Next.js 16 App Router app — i18n routing, GROQ data fetching, Portable Text, Mux, sitemap/robots, cache-tag revalidation |
| Studio | studio/ |
Sanity Studio v5 — schema, plugins, Presentation, dev/prod dataset sync |
@repo/sanity-dataset-resolve |
packages/sanity-dataset-resolve/ |
Shared dev/prod dataset resolution used by both web and Studio |
@repo/strip-readmes |
packages/strip-readmes/ |
Bulk-clean nested READMEs when you ship your fork (the root README is preserved) |
@repo/scaffold-module |
packages/scaffold-module/ |
pnpm gen:module <Name> — scaffolds a new content module across all 8 wiring points |
@repo/check-wiring |
packages/check-wiring/ |
pnpm check:wiring — validates module wiring across schema, component, query, and type files (CI gate) |
git clone <your-fork>.git my-site
cd my-site
pnpm installCopy the env templates and set your project id (the only required variable for local dev):
cp web/.env.example web/.env.local
cp studio/.env.example studio/.env
# Set SANITY_STUDIO_PROJECT_ID in both files (same project). Everything else has sensible defaults.Optional but recommended: create a development dataset in Sanity (UI or CLI) so you never edit production while iterating — or pin SANITY_STUDIO_DATASET=production to skip dataset splitting.
Start Studio and the web app:
pnpm dev # both in parallel
# or:
pnpm studio:dev # Studio at http://localhost:3333
pnpm web:dev # Web at http://localhost:3000In Studio, open Settings → Site languages (pre-filled en + de on first create, default en), publish, then create a page with a unique slug. Visit http://localhost:3000/<locale>/<slug> — done.
Variables you actually care about — full comments live in web/.env.example and studio/.env.example.
| File | Variable | Required | Purpose |
|---|---|---|---|
web/.env.local + studio/.env |
SANITY_STUDIO_PROJECT_ID |
yes | Your Sanity project id |
web/.env.local |
SANITY_API_READ_TOKEN |
optional | Draft mode + server-side draft reads |
web/.env.local |
SANITY_REVALIDATE_SECRET |
prod yes | HMAC secret for /api/revalidate (fail-closed in prod) |
web/.env.local |
NEXT_PUBLIC_SITE_URL |
yes (prod) | Used by sitemap.ts, robots.ts, metadata base |
| both | SANITY_STUDIO_DEPLOYMENT_TARGET |
optional | Switch dataset per environment (staging, development, …) |
| both | SANITY_STUDIO_DATASET |
optional | Hard pin a specific dataset name (skips auto-resolution) |
studio/.env |
SANITY_STUDIO_PREVIEW_ORIGIN |
yes (Studio) | Origin used by the Presentation iframe |
studio/.env |
SANITY_STUDIO_MUX_TOKEN_ID / _SECRET |
optional | Mux uploads in Studio |
- Explicit pin wins —
SANITY_STUDIO_DATASET/NEXT_PUBLIC_SANITY_DATASET. - Otherwise,
SANITY_STUDIO_DEPLOYMENT_TARGETdecides preference order:production→ preferproduction, fall back todevelopment.development/preview/ unset → preferdevelopment, fall back toproduction.
- Canonical names are configurable (
SANITY_STUDIO_DATASET_DEVELOPMENT/_PRODUCTION). - If needed, the resolver enumerates actual datasets via the Management API (
SANITY_STUDIO_DATASET_RESOLVER_TOKEN/SANITY_API_READ_TOKEN) to avoid requesting one that doesn't exist.
Consumed by web/sanity/resolveStudioDataset.ts + web/sanity/sanityEnv.ts and studio/config/sync/studioDataset.ts.
The deep-dive lives in the per-folder READMEs (see Going deeper). The short version:
- Data access —
web/sanity/fetchSanityData.tsis the only place React talks to Sanity; it wrapssanityFetchfromdefineLivefor Draft Mode / Presentation. Published-only reads (sitemap,generateStaticParams, no-token paths) go throughweb/sanity/cachedSanityQuery.ts(unstable_cache+ cache tags). - GROQ — strings live under
web/sanity/queries/(snippets, page-level queries, component projections). The final queries are wrapped indefineQuerysosanity typegenemits result types. - i18n — Sanity's
siteLanguageSettingssingleton drives URL routing,<html lang>,hreflangalternates, and the language tabs in Studio. A minimalen-only fallback applies only when the document is missing/invalid (CI, first deploy). - Media —
<MediaImage />,<MediaVideo />,<MediaVideoLoop />share container measurement, hydration-safe URLs, and viewport-deferred JS.
All from the repo root.
| Script | What it does |
|---|---|
pnpm dev |
Run web + studio in parallel |
pnpm web:dev |
Web only (next dev --webpack) |
pnpm studio:dev |
Studio only |
pnpm build |
Build all workspaces |
pnpm studio:build / studio:deploy |
Build / deploy Studio to Sanity hosting |
pnpm studio:generate |
Schema extract + GROQ typegen |
pnpm studio:sync-prod-to-dev |
Clone production → development dataset |
pnpm lint |
Biome check (repo-wide) |
pnpm format |
Biome --write (repo-wide) |
pnpm typecheck |
Recursive tsc --noEmit across workspaces |
pnpm update |
pnpm up -r for the whole repo |
pnpm strip-readmes |
Remove nested per-folder READMEs when shipping your fork (root README.md is preserved). Pair with :dry-run to preview. |
pnpm gen:module <Name> |
Scaffold a new content module across the 8 wiring points. PascalCase name; --dry-run to preview, --inline to also register inside Portable Text. |
pnpm check:wiring |
Validate the 8-point module wiring (schema, component, query, type, and their barrels/registrations). Runs in CI. |
Per-package: pnpm --filter <web|studio> run <script>.
- Biome — root
biome.jsonis the single source of truth.web/andstudio/call Biome viapnpm --workspace-root exec biome …; Studio uses a 2-space override, the rest of the repo is tabs. - Husky —
.husky/pre-commitrunspnpm run format;.husky/pre-pushrunspnpm run format && pnpm run typecheck. - GitHub Actions —
.github/workflows/ci.ymlrunspnpm run format(Biomecheck --write) + agit diff --exit-codeguard +pnpm run typecheckon Node 20 & 22,pnpm studio:generateandpnpm --filter web run generatewith diff guards on the committed typegen artifacts, plusnext buildandsanity buildsmokes. - Dependabot — weekly npm updates, Sanity plugins grouped,
@types/nodemajor bumps explicitly ignored (typings track Node 22 LTS). - TypeScript — strict, ES2022 target. Root
pnpm typecheckwalks every workspace package's owntypecheckscript. - Commit hygiene —
.DS_Store,*.tsbuildinfo,coverage/, and editor caches under.cursor/are ignored; the committed.cursor/rules/folder (Cursor IDE rules) is explicitly re-included. See root.gitignore. - Agent guardrails — AI coding assistants (Claude Code, Cursor, Windsurf, Copilot) read
AGENTS.mdat the repo root. Sub-paths get auto-loadedCLAUDE.mdfiles and.cursor/rules/*.mdcrules with file-glob scoping. UpdateAGENTS.mdfirst; the others defer to it.
netlify.toml ships with:
- A dataset-aware
build.ignore— commits that only touchstudio/don't rebuild the web app. - Security headers — CSP (with
frame-ancestorsallowingsanity.studiofor Presentation), HSTS preload,Permissions-Policy,Referrer-Policy,X-Content-Type-Options. - Long-lived cache for
/_next/static/*.
Set on the host: SANITY_STUDIO_PROJECT_ID, SANITY_API_READ_TOKEN (optional), SANITY_REVALIDATE_SECRET (required in prod), NEXT_PUBLIC_SITE_URL, SANITY_STUDIO_DEPLOYMENT_TARGET (only if you want to point at a non-production dataset).
pnpm studio:deploy ships to Sanity's hosted Studio (*.sanity.studio). The deploy script forces SANITY_STUDIO_DEPLOYMENT_TARGET=production.
Configure the Revalidate webhook in Sanity Manage → API → Webhooks, targeting https://<your-site>/api/revalidate with the same SANITY_REVALIDATE_SECRET (signed delivery).
Everything is standard Next.js + Sanity — mirror the env vars and you're done. CSP / HSTS live in netlify.toml; port the equivalent headers to your platform's config.
web/README.md— app-level details, module conventions.web/sanity/README.md— GROQ layer, client config, dataset resolution.web/src/i18n/README.md— localisation flow end-to-end.studio/README.md— Studio customisation, plugins, schema patterns.packages/sanity-dataset-resolve/src/index.ts— shared dev/prod resolver, API documented inline.
- Node.js LTS — 20 minimum, 22 recommended (CI tests both).
- pnpm 10 (pinned in root
package.jsonpackageManager). - Sanity project with a project id.
- (optional) Mux API tokens for video uploads.
- (optional) Netlify for hosting — the included
netlify.tomlis production-ready.
ISC. Use it, fork it, ship it.