Naalya Handbook
Sanity CMS

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.

Almost everything you see on the school website is editorial content, not code. The marketing copy, the campus pages, the leadership directory, blog posts, announcements, the global site settings — none of it is hardcoded. It all lives in Sanity, and the React Router frontend pulls it in at request time. Change a headline in Sanity, and it shows up on the site. No deploy.

The whole integration comes down to two surfaces and one client. Get that mental model straight and the rest of this section is just detail.

The two surfaces

There's the place editors work, and the place the website reads. They live in the same Git repo but are otherwise strangers — they only ever meet through the shared Sanity dataset.

  • The Studio (sanity/) is the editing UI — schemas, desk structure, the Presentation preview, plugins. It's Sanity v5, and it's where content gets created and changed.
  • The frontend client (app/) is the React Router site. It reads content (and, for forms, writes it) through a single @sanity/client instance.

Both point at the same Sanity project and dataset — project hdefozzv, dataset development — so an edit in the Studio is immediately visible to the frontend's queries.

Studio identity is hardcoded; the frontend's is env-driven

The Studio bakes projectId and dataset straight into sanity/sanity.config.ts as literal strings. The frontend reads the same values from VITE_SANITY_* env vars instead. If you ever fork or rename the project, you must change both places — and they must agree, or the site quietly reads an empty dataset.

The Studio is not in the pnpm workspace

This is the single most common trap in the repo, so internalize it now: the Studio is a standalone pnpm project. The root pnpm-workspace.yaml has no packages: key, and sanity/ carries its own pnpm-lock.yaml and its own node_modules. It is not a workspace member.

The practical consequence is that you run each side from its own directory:

terminal
# Frontend (root project) — from the repo root
pnpm install
pnpm dev            # → http://localhost:5173

# Studio (standalone) — from inside sanity/
cd sanity
pnpm install        # first time only; it has its own lockfile
pnpm dev            # → http://localhost:3333

Do not reach for pnpm --filter

pnpm --filter <studio> dev from the repo root will fail — the Studio isn't a workspace package, so there's nothing to filter. You have to cd sanity first. Setup has the exact commands and the env vars each side expects.

What Sanity powers

The content model has one big idea behind it: a page is a singleton that points at section documents. Each web page is a single document holding SEO fields plus one reference per visible block — hero, stats, FAQ, gallery — and every block is its own top-level document. That's why "edit the homepage" means editing several linked documents.

Here's the lay of the land:

Content areaBacked by
Landing / About / Admissions / Leadership / Contact pagesPage singleton documents (landingPage, aboutPage, …)
Campus pages (Namugongo, Lugazi, Bweyogerere)Per-campus page singletons + section documents
On-page sections (hero, stats, facilities, FAQ, …)Standalone section documents, referenced by a page (~75 types)
Blog / Updates postspost documents — the only non-singleton list content
Announcements & global site settingsannouncement and siteSettings singletons

Singletons are enforced by the Studio, not the schema

Nothing in a schema marks a type as "there's only one." That behavior comes entirely from the desk structure pinning a fixed documentId and hiding the type from the generic create-new list. The frontend then fetches each one by that fixed id — *[_type == "siteSettings" && _id == "siteSettings"][0]. The full pattern lives in Single Documents & Singletons.

The one client

Here's the part that surprises people coming from other Sanity setups: there is exactly one @sanity/client in the entire app, and it does everything — reads, writes, and image URLs. No separate read-only CDN client, no @sanity/react-loader, no defineLive. One file, and you've seen 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, // write token from env
});

export default sanityClient;

Two choices in there carry real weight. useCdn: false means every read bypasses Sanity's CDN — the price you pay for letting the same client also write. And that token is what makes those writes possible: the contact and enquiry forms mutate documents directly with it.

Security: 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 — every visitor receives it. The form mutations in app/lib/sanity-mutations.ts therefore run client-side with a publicly readable token. Treat this as a known exposure: don't widen the token's scope, and flag it in any security review. The proper fix is a server-side mutation endpoint with a token that never reaches the bundle.

That single client gets reached in four ways, and the split is worth knowing up front: global data (site settings, favicon) is fetched in root.tsx's server loader(), while page data is fetched browser-side through TanStack Query. The same client also creates documents for form submissions and builds optimized image URLs. Querying with GROQ walks through the read pipeline end to end.

A note on freshness and preview

Two things commonly trip up newcomers, so set expectations now. The Studio's Presentation tool loads the live site in an iframe for click-to-edit overlays, but it's wired minimally — only a hardcoded previewUrl of http://localhost:5173, no per-document preview links. And because there's no defineLive/live-query setup, content does not update in real time on the frontend; how fresh a page is comes down to its TanStack Query cache preset (mostly cache_24_hours).

The frontend uses hand-written DTOs, not generated types

The site does not consume Sanity's generated TypeScript types. Each query module hand-maintains its own DTO in interfaces/<feature>.dto.ts. That keeps things simple, but it means a DTO can silently drift from the schema — when you change a schema field, update the matching DTO by hand.

Where to go next

This page is the map. Each conventions below gets its own page:

On this page