Naalya Handbook

How It's Built

The mental model of the Naalya site — three sources of truth, and how a request becomes a fully-rendered page with no spinner.

Before you open a single file, get the shape of the thing in your head. The Naalya website looks like one app, but under the hood it's a renderer stitching together three independent sources of truth — and almost every decision in the codebase falls out of that one idea. Learn the shape first, and the folders, the loaders, and the conventions all stop feeling arbitrary.

Here's the whole site in one sentence: React Router renders pages on the server, pulling editorial content from Sanity and application data from the Naalya Schools API, then ships a warm cache to the browser so nothing flickers. Everything below is just that sentence, slowed down.

Three sources of truth

The trick to reading this codebase is knowing which of the three a given piece of data comes from — because that single fact tells you which folder it lives in, which client fetches it, and how it gets cached.

The first is editorial content — the words, images, and page structure that non-developers edit. That lives in Sanity, a headless CMS, and you read it with GROQ queries. If a marketer can change it without a deploy, it's Sanity content.

The second is application data — students, admissions, the things the school's backend owns. That comes from the Naalya Schools API, a NestJS service over https://tunnel.chowbea.com, reached through a generated, fully type-safe axios client. If it's dynamic, transactional, or tied to a real record, it's API data.

The third isn't data at all — it's the renderer. React Router v7 in framework mode runs the whole thing server-side first, then hands off to the browser. It's the stage the other two perform on.

The one question to ask before adding any data

"Is this content or application data?" Content goes through Sanity and GROQ (app/queries/**). Application data goes through the generated API client (app/services/api/). They are different clients, different caches, and different mental models — and conflating them is the most common way to put code in the wrong place.

Here's the picture, with the direction of every arrow:

The shape of the site
  Editors                    Visitors
     │                          │
     ▼                          ▼
┌──────────┐  GROQ    ┌───────────────────────┐  axios   ┌──────────────────┐
│  Sanity   │────────▶│   React Router v7     │─────────▶│ Naalya Schools   │
│  Studio   │ content │   (SSR renderer)       │ app data │ API (NestJS)     │
│ (sanity/) │         │   app/                 │          │ tunnel.chowbea   │
└──────────┘          └───────────────────────┘          └──────────────────┘

Why three, and why these three

You could have built this as a single backend that owns everything. The split exists on purpose, and each side earns its keep.

Sanity owns content because editors aren't developers. Marketing needs to rewrite the homepage hero at 4pm on a Friday without filing a ticket or waiting for a deploy. A headless CMS gives them a real editing UI (the Studio) while the frontend stays a clean React app that just reads the result. The price is a second system to learn — which is most of this handbook's Sanity section.

The API stays separate because the school's real data has a real owner. Student records and admissions logic belong to a backend with its own lifecycle, not bolted onto a marketing site. The frontend treats it as a typed contract: the backend publishes an OpenAPI spec, and the chowbea-axios CLI generates a client from it. Change an endpoint, regenerate, and TypeScript tells you exactly what broke before it ships.

React Router does SSR because this is a public school site, and SEO and first paint matter. Server-rendering means crawlers and slow phones get real HTML on the first byte — not a blank page waiting for JavaScript. That single requirement is the seed of the whole request lifecycle below.

The generated client is the contract — never hand-edit it

The API client in app/services/api/_generated/ is overwritten on every regeneration. Its types come straight from the live OpenAPI spec, so they can't silently drift from the backend. Your customizations go in the hand-owned wrapper files (api.client.ts, api.instance.ts) — see The API Client. Treat the generated folder as machine output, not source.


How a request becomes a page

Now the part that ties it together. When a visitor hits a URL, the page is fully rendered on the server before a single byte reaches them — and when the browser takes over, the data is already there. No spinner, no second fetch, no flash of empty layout. That magic is a four-step handshake between React Router and TanStack Query, and once you've seen it once you'll recognize it in every route.

The supporting cast: TanStack Query is the cache that both sides share, and getQueryClient() (app/lib/query-client.ts) is the SSR-safe way to reach it — a fresh client per request on the server, a singleton in the browser.

Step 1: The loader prefetches on the server

Every content route exports a loader. It runs on the server, grabs the request-scoped query client, and prefetches every query the page needs — in parallel, so three slow CMS calls cost one round trip, not three:

app/routes/landing.tsx (loader)
export async function loader() {
  const queryClient = getQueryClient();

  // Prefetch everything the page renders, in parallel
  await Promise.all([
    queryClient.prefetchQuery(LandingOptions.landingData()),
    queryClient.prefetchQuery(CampusListOptions.campusListData()),
    queryClient.prefetchQuery(BlogOptions.blogPosts()),
  ]);

  return { dehydratedState: dehydrate(queryClient) };
}

Step 2: The cache is dehydrated into the response

That last line is the hinge. dehydrate() serializes the warm cache — every query result the loader just fetched — into a plain object, and the loader returns it. React Router ships it down with the server-rendered HTML, so the browser receives the page and its data in the same payload.

Step 3: HydrationBoundary rehydrates on the client

The route component wraps its content in a <HydrationBoundary>, handing back the cache the loader serialized. This re-warms the browser's query client with the server's results the instant the page loads:

app/routes/landing.tsx (component)
export default function Landing({ loaderData }: Route.ComponentProps) {
  return (
    <HydrationBoundary state={loaderData.dehydratedState}>
      {/* sections live in here; each reads its own data */}
    </HydrationBoundary>
  );
}

Step 4: Components read the warm cache — no spinner

Inside that boundary, each section calls its own use-* hook. Because the loader already prefetched the data and HydrationBoundary already rehydrated it, the hook's useQuery finds the answer sitting in the cache and returns instantly — the loading branch never runs:

app/components/sections/academic-overview.tsx
const { academicOverviewData, isLoading } = useAcademicOverview();

// On a hydrated page this branch is skipped — the data is already cached
if (isLoading || !academicOverviewData) return null;

Await the prefetch, or the cache ships empty

dehydrate() only captures what's already resolved in the query client. The await Promise.all(...) in the loader is load-bearing: drop it and the loader returns before the fetches finish, so the dehydrated state is empty, hydration warms nothing, and every component falls back to its loading branch on first paint. Prefetch, await, then dehydrate.

The shape is always the same: loader prefetches → dehydrate → HydrationBoundary → hook reads the warm cache. Roughly ten routes follow it verbatim. Routing & Pages walks the full route-module contract, and Adding a Page wires one up end to end.


The choices that shaped everything

A few opinions ripple through the whole codebase. Knowing them up front saves you from fighting the grain.

Content vs. application data, kept apart

Sanity content and API data never share a client or a cache. The split decides which folder, which fetcher, and which DTOs you reach for.

Server-first rendering, always

SSR is on by default. Loaders prefetch so crawlers and slow phones get real HTML — the prefetch-and-hydrate dance is the price of that.

One query client, obtained safely

Never call new QueryClient(). getQueryClient() gives a fresh instance per server request (no cross-user leakage) and a singleton in the browser.

Types come from contracts, not by hand

API types are generated from the OpenAPI spec. The generated folder is machine output — edit the wrappers, never the _generated/ files.

One honest seam: Sanity DTOs are hand-written

The API client's types are generated and can't drift. Sanity's types are not — the DTOs under app/queries/<page>/interfaces/ are written by hand and the query result is as-casted. Add a field to a GROQ projection and you must add it to the DTO yourself; TypeScript won't catch the mismatch. Two data sources, two very different safety guarantees — don't assume the Sanity side is as bulletproof as the API side.


Where to go next

You've got the map. Pick the territory you need.

On this page