Naalya Handbook
Sanity CMS

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.

terminal
cd sanity
pnpm install        # installs against sanity/pnpm-lock.yaml
pnpm dev            # runs `sanity dev` -> http://localhost:3333

Open 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:

ScriptRunsWhen you reach for it
pnpm devsanity devLocal Studio on 3333 — your default
pnpm buildsanity buildProduction build
pnpm deploysanity deployPush the hosted Studio (uses appId)
pnpm typegenschema extract + typegenRegenerate 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:

app/lib/sanity-client.ts
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:

.env
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:

terminal
pnpm install
pnpm dev            # react-router dev --host -> http://localhost:5173

The 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:

app/queries/landing/landing.options.ts
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:

app/helpers/optimize-sanity-image.ts
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.

Dockerfile (ARG block)
ARG VITE_SANITY_PROJECT_ID
ARG VITE_SANITY_DATASET
ARG VITE_SANITY_API_VERSION
ARG VITE_SANITY_WRITE_TOKEN

Changing 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.

On this page