Setup
Run the Sanity Studio and wire the frontend @sanity/client — project, dataset, and the env vars that make both go.
There are two Sanity apps in this repo, and they run separately. Get that straight first and everything else clicks into place. The Studio in sanity/ is the editing app — the desk where content authors actually type. The frontend at the repo root is the React Router site, and it reads (and writes) that content through a single @sanity/client. Same Sanity project on the backend; two independent codebases on your machine.
This page gets both running. You'll start the Studio on port 3333, then point the frontend client at the project with a handful of env vars. Nothing here is conceptual heavy-lifting — it's mostly knowing which folder to stand in and which four variables to set.
One project, two clients
Both apps talk to the same Sanity project — id hdefozzv, dataset development. The Studio hardcodes those values; the frontend reads them from env. That asymmetry is the single most common source of "why won't it connect" confusion, so keep it in mind.
Step 1: Run the Studio
The Studio is a self-contained pnpm project that lives in sanity/. It carries its own pnpm-lock.yaml and its own node_modules, which means you install and run it from inside that folder — never from the repo root.
cd sanity
pnpm install # installs against sanity/pnpm-lock.yaml
pnpm dev # runs `sanity dev` -> http://localhost:3333Open http://localhost:3333 and you're in the editing app. The project id and dataset are baked into sanity.config.ts as literal strings, so the Studio needs no env file to boot — it knows where to connect.
`pnpm --filter` will not find the Studio
pnpm-workspace.yaml has no packages: key — only a build-approvals block — so the sanity/ folder is not a workspace member. Running pnpm --filter <studio> dev from the repo root fails every time. The fix is never a flag; it's cd sanity first.
Day to day you only ever need pnpm dev. The other scripts are there when you need them:
| Script | Runs | When you reach for it |
|---|---|---|
pnpm dev | sanity dev | Local Studio on 3333 — your default |
pnpm build | sanity build | Production build |
pnpm deploy | sanity deploy | Push the hosted Studio (uses appId) |
pnpm typegen | schema extract + typegen | Regenerate types from the schema |
To see live preview — the click-to-edit overlays in the Presentation tool — the Studio isn't enough on its own. Its preview iframe points at http://localhost:5173, which is the frontend. So you'll want both running, which is exactly what Step 2 sets up.
Step 2: Configure the frontend client
The frontend has exactly one @sanity/client instance, and it's already written for you at app/lib/sanity-client.ts. You do not create it — you just feed it env vars. Here's the whole thing:
import { createClient } from "@sanity/client";
const sanityClient = createClient({
projectId: import.meta.env.VITE_SANITY_PROJECT_ID,
dataset: import.meta.env.VITE_SANITY_DATASET,
apiVersion: import.meta.env.VITE_SANITY_API_VERSION,
useCdn: false,
token: import.meta.env.VITE_SANITY_WRITE_TOKEN, // reads AND writes
});
export default sanityClient;Two design choices are worth understanding before you wire it up. useCdn: false means reads bypass Sanity's CDN — deliberate, because the same client also performs writes (form submissions). And all four values come from VITE_-prefixed env with no fallbacks, so a missing variable is simply undefined, and fetches then fail at runtime with no friendly message.
Create your .env
There is no committed .env or .env.example anywhere — .env* is gitignored at both levels. So you create one yourself, at the repo root, with these four variables:
VITE_SANITY_PROJECT_ID=hdefozzv
VITE_SANITY_DATASET=development
VITE_SANITY_API_VERSION=2024-01-01
VITE_SANITY_WRITE_TOKEN=<sanity token with write scope>The first two are fixed for this project. VITE_SANITY_API_VERSION is a date string with no in-code default — pick a fixed date and leave it. The write token you generate yourself in the Sanity project's API settings; give it write scope.
The write token ships to the browser
VITE_SANITY_WRITE_TOKEN is write-scoped, and because it's VITE_-prefixed, Vite inlines it into the client bundle — so the token is delivered to every visitor. Form mutations run client-side with a publicly-readable token. Treat it as already exposed, and flag it before relying on it in production; the proper fix is a server-only mutation path.
Run the frontend
With the .env in place, install and start the site from the repo root:
pnpm install
pnpm dev # react-router dev --host -> http://localhost:5173The site comes up on http://localhost:5173 — the same URL the Studio's preview iframe expects, so live editing lines up by default. Want the generated-API watcher running alongside? Use pnpm dev:all.
No env validation means silent failures
createClient happily accepts undefined for any unset variable — nothing validates your .env. The symptom is fetches that fail at runtime with no clear cause. If data isn't loading, check the root .env first; a typo'd or missing var is the usual culprit.
Step 3: Use the client
Once the client has its env, you fetch by importing the default export. Anywhere in the app, the same instance is the single source of truth:
import sanityClient from "@/lib/sanity-client";
const data = await sanityClient.fetch(LANDING_PAGE_QUERY);That one import covers reads, writes, and image transforms — there is no separate read/CDN client, no @sanity/react-loader, no live-query setup. For images specifically, reach for the helpers rather than building URLs by hand:
import { optimizeSanityImage } from "@/helpers/optimize-sanity-image";One client, reused everywhere
The same sanityClient is consumed in four places: the root loader() for site-wide data, TanStack Query options for page data, mutations in sanity-mutations.ts, and @sanity/image-url for images. Freshness comes from the query cache (mostly 24 hours), not live subscriptions. Querying with GROQ covers the per-page module convention.
Deploying: VITE_ vars are build-time
One gotcha that bites on first deploy. Because VITE_-prefixed variables are inlined into the bundle at build time, you can't set them as runtime environment variables on the server. When you build the Docker image (Railway), pass the same VITE_SANITY_* values as build ARGs.
ARG VITE_SANITY_PROJECT_ID
ARG VITE_SANITY_DATASET
ARG VITE_SANITY_API_VERSION
ARG VITE_SANITY_WRITE_TOKENChanging any of these after a build means rebuilding — there is no runtime override.
Ignore the unused ARGs
The Dockerfile also declares VITE_SANITY_DEV_APP_ID, VITE_SANITY_PROD_APP_ID, and VITE_BASE_URL, but nothing in the code references them. They're reserved or dead — don't assume they're wired up, and don't waste time setting them.
Where to go next
You have both apps running and the client connected. From here, learn the content model and how to read it.
The Content Model
What the documents look like and how schemas are registered.
Querying with GROQ
The per-page query-module convention and DTOs the client uses.
Single Documents & Singletons
Fetching one document — the fixed-_id singleton pattern behind every page.
Getting Started
The bigger picture: installing and running the whole project from scratch.
Sanity CMS
How the school website integrates Sanity — the Studio you edit in, the frontend that reads, and the one client that ties them together.
The Content Model
The big-picture shape of the Sanity Studio — pages as singletons, sections as references, shared objects, one rich-text type, and a flat registry.