Naalya Handbook

Adding a Page

Build a CMS-backed page end to end — query module, SSR loader, route registration, SEO, and typegen.

Adding a page to the Naalya site is less about writing a component and more about wiring data through a pipeline. The site is React Router v7 in framework mode with SSR on globally, so the goal is simple: a crawler hits your URL and gets fully rendered HTML — hero text, images, the lot — with no loading spinner in sight.

The way you get there is a small, repeatable assembly line. Every CMS page is built from the same parts:

  • A query module that knows how to fetch the data from Sanity.
  • A route module with an SSR loader that prefetches that data and hands the warm cache to the browser.
  • An entry in app/routes.ts that maps a URL to your file.
  • A meta() function for SEO.
  • A run of typegen so the per-route types resolve.

Miss any one of them and the page doesn't work — usually silently. This page walks all six steps building a fictional /scholarships page. The cleanest real reference in the repo is app/routes/admissions.tsx; keep it open alongside this guide.

The two steps everyone forgets

A file in app/routes/ does nothing on its own. A page exists only once you (1) register it in app/routes.ts and (2) run react-router typegen so the ./+types/<name> import resolves. The .react-router/types/ folder is gitignored, so a fresh checkout always needs typegen before the types are real.

Here's the shape of the whole flow before we build it — data prefetched on the server, dehydrated, then read from a warm cache on the client:

the SSR data flow
loader (server)
  getQueryClient() → prefetchQuery(...) → dehydrate()


component  { loaderData }: Route.ComponentProps
  <HydrationBoundary state={loaderData.dehydratedState}>


inner content reads via use<Name>()   ← cache is warm, no spinner

Step 1: Build the query module

Every CMS-backed page owns a folder at app/queries/<name>/ holding four small pieces — a GROQ query, a queryOptions factory, a use* hook, and a DTO type. This is the same convention you met in Querying with GROQ; here's the minimum to wire a page.

Start with the query. defineQuery is a typed identity tag from the groq package — it just returns the string, but it lets the tooling type the result. Export it in SCREAMING_SNAKE_CASE, and trim the projection to the shape you actually need:

app/queries/scholarships/scholarships.query.ts
export const SCHOLARSHIPS_PAGE_QUERY = defineQuery(`*[
  _type == "scholarshipsPage" && _id == "scholarshipsPage"
][0]{
  _id, title, seoTitle, seoDescription,
  hero->{ heading, backgroundImage{ asset->{ url, metadata } } }
}`);

That [0] matters — a GROQ filter always returns an array, and [0] collapses it to the single document (or null). This is a singleton page, addressed by a fixed _id; see Single Documents & Singletons for why the type name is the address.

Next, the options factory — a class with a static method returning queryOptions. The queryKey is the contract that the loader, the hook, and meta() all share, so pick it deliberately: content pages use ["page", "<name>"]. Spread a cache preset from app/queries/cache-info.tsx last:

app/queries/scholarships/scholarships.options.ts
export class ScholarshipsOptions {
  static scholarshipsData() {
    return queryOptions({
      queryKey: ["page", "scholarships"],
      queryFn: async () => await sanityClient.fetch(SCHOLARSHIPS_PAGE_QUERY),
      ...cache_24_hours,
    });
  }
}

Finally a hand-written DTO and a thin hook over those same options. The hook is what the component calls — and because the loader already filled the cache, it reads synchronously on hydration instead of firing a fresh request:

app/queries/scholarships/use-scholarships.tsx
const useScholarshipsPageData = () => {
  const { data, ...rest } = useQuery(ScholarshipsOptions.scholarshipsData());
  return { scholarshipsPageData: data as ScholarshipsPageData, ...rest };
};

Cache presets, not magic numbers

cache-info.tsx exports ready-made { staleTime, gcTime } objects from cache_1_minute up to cache_30_days. CMS pages change rarely, so they use cache_24_hours. Spread the preset last so it sets the cache fields without you retyping the milliseconds.


Step 2: Write the route module

Create app/routes/scholarships.tsx. A route module in this codebase is deliberately small — per page you export only a loader, a meta, and the default component. There are no action, clientLoader, or headers exports here; links and ErrorBoundary live in app/root.tsx alone.

The loader runs on the server. It grabs the shared query client, prefetches your data, and returns the dehydrated cache — never the data itself:

app/routes/scholarships.tsx
export async function loader() {
  const queryClient = getQueryClient();
  await queryClient.prefetchQuery(ScholarshipsOptions.scholarshipsData());
  return { dehydratedState: dehydrate(queryClient) };
}

The component comes in two halves, and the split is load-bearing. The default export is a thin wrapper that mounts <HydrationBoundary> with the dehydrated state; the inner …Content component is where you call the hook and render. Keeping them separate is exactly what lets the hook read from a hydrated cache instead of refetching:

app/routes/scholarships.tsx
const ScholarshipsPage = ({ loaderData }: Route.ComponentProps) => (
  <HydrationBoundary state={loaderData.dehydratedState}>
    <ScholarshipsPageContent />
  </HydrationBoundary>
);

const ScholarshipsPageContent: React.FC = () => {
  const { scholarshipsPageData } = useScholarshipsPageData();
  const hero = scholarshipsPageData?.hero;
  return (
    <section className="mx-auto w-full max-w-8xl px-4 py-16 md:px-10">
      <h1 className="font-semibold text-4xl md:text-5xl">
        {hero?.heading || "Scholarships at Naalya Schools"}
      </h1>
    </section>
  );
};

export default ScholarshipsPage;

Two rules are baked into that file. Data arrives through the typed loaderData prop destructured from Route.ComponentPropsuseLoaderData() is never used in this app, so don't reintroduce it. And imports use the @/ alias (@/* → ./app/*); only the ./+types/<name> import is relative, because it points at a generated sibling.

The loader returns dehydrated state, not page data

The tempting mistake is return { scholarships: data }. Don't. The loader must return { dehydratedState: dehydrate(queryClient) }, and the component reads through the use* hook behind <HydrationBoundary>. Return the raw data directly and the hook refetches on the client — you lose the entire SSR-warm cache and the spinner comes back.


Step 3: Register the route in app/routes.ts

app/routes.ts is the single source of truth for routing — there is no file-based convention here. Add your page inside the layout("routes/_layout.tsx", [...]) block so it inherits the Navbar, Footer, and the rest of the site chrome. Drop it among the other route(...) lines, above the legacy-redirect comment:

app/routes.ts
export default [
  layout("routes/_layout.tsx", [
    index("routes/landing.tsx"),
    route("/about", "routes/about.tsx"),
    route("/admissions", "routes/admissions.tsx"),
    route("/scholarships", "routes/scholarships.tsx"), // ← add this
    // ...other pages...

    // Legacy URL redirects (301 permanent)
    route("/about-us", "routes/about-us.tsx"),
  ]),
] satisfies RouteConfig;

The four routing helpers do exactly what their names suggest — this is the whole vocabulary:

HelperAdds a URL segment?Adds a UI wrapper?Use it for
route(path, file)Yes (the path)NoA normal page like /scholarships
index(file)No (the parent's /)NoThe default child of a layout
layout(file, children)No (pathless)Yes (<Outlet/>)Wrapping a group in shared chrome
prefix(path, children)Yes (prepended)NoGrouping URLs without a layout

The filename is cosmetic — routes.ts owns the URL

The URL comes entirely from the path string in route(...), not the file name. That's how campus files are named campuses/lugazi.campus.tsx while serving /campuses/lugazi. Name files for clarity; the manifest decides the route.


Step 4: Wire SEO with meta()

Because the loader returns a dehydrated cache instead of a flat object, meta() can't just read data.scholarships. It has to dig into data.dehydratedState.queries and find the entry whose queryKey matches — compared with JSON.stringify. This block is identical on every page; you only swap the key:

app/routes/scholarships.tsx
export function meta({ data }: Route.MetaArgs) {
  const pageData = data?.dehydratedState?.queries?.find(
    (q) => JSON.stringify(q.queryKey) === JSON.stringify(["page", "scholarships"])
  )?.state?.data as { seoTitle?: string; seoDescription?: string } | undefined;

  const title = pageData?.seoTitle || "Scholarships | Naalya Schools";
  const description =
    pageData?.seoDescription || "Scholarship opportunities at Naalya Schools.";

  return [
    { title },
    { name: "description", content: description },
    { property: "og:title", content: title },
    { property: "og:description", content: description },
    /* ...the full Open Graph + Twitter set — copy from admissions.tsx */
  ];
}

Always give title and description hardcoded || fallbacks so the page has valid SEO even before the CMS fields are filled in, and return the full Open Graph plus Twitter set like admissions.tsx does.

The queryKey must match byte-for-byte

If the queryKey in meta() doesn't exactly match the one in your options factory, find returns undefined and SEO silently falls back to the hardcoded defaults — no error, just generic tags on every share. Keep ["page", "scholarships"] mentally in one place: the options factory and meta() both reference it.


Step 5: Build the UI from existing components

Compose the page from the shared library rather than hand-rolling markup — the full catalog is in Components. A few conventions matter here: buttons, cards, and inputs come from @/components/ui/* (shadcn, new-york style); Sanity images go through OptimizedImage so you get WebP/AVIF, responsive srcSet, and an LQIP blur placeholder; rich text renders through <PortableText>. Internal navigation always uses <Link> from react-router with the viewTransition prop:

app/components/.../some-section.tsx
import { Link } from "react-router";

<Link to="/scholarships" viewTransition>Scholarships</Link>;

If the page should highlight in the navbar, add a manual check in navbar.tsx — active styling is computed by hand from useLocation().pathname, and NavLink is not used anywhere:

app/components/navigation/navbar.tsx
const isScholarships = pathname.includes("/scholarships");

Watch for false-positive .includes() matches

Active-link styling uses pathname.includes(...), a substring match. Pick a fragment that can't accidentally match another route — /about would also light up on /about-us. When in doubt, mirror the existing hand-rolled checks rather than mixing in NavLink.


Step 6: Run typegen and verify

The ./+types/scholarships import only resolves after typegen writes it. Run the typecheck script — it runs react-router typegen and then tsc:

terminal
pnpm typecheck

Or just start the dev server. The reactRouter() Vite plugin triggers typegen automatically, so booting the app is enough to make the types real:

terminal
pnpm dev

Either way writes .react-router/types/app/routes/+types/scholarships.ts, which exports the Route namespace — LoaderArgs, MetaArgs, ComponentProps, and friends. Because that folder is gitignored, CI and every fresh checkout must run typegen before tsc will pass.

Now visit http://localhost:5173/scholarships. The page renders inside the site chrome, and — the real test — view-source shows the hero content already in the HTML, not an empty shell. That's the SSR loop working end to end.


The mental checklist

Four things tend to break a new page, all silent. Keep them in view:

Returned dehydratedState, not data

The loader returns { dehydratedState: dehydrate(queryClient) }. Return raw data and the hook refetches on the client.

queryKey matches everywhere

The same ["page", "<name>"] in the options factory and in meta(). A mismatch silently drops your SEO to defaults.

Registered in routes.ts

Inside layout(...) for chrome. A file alone serves nothing.

Typegen has run

pnpm typecheck or pnpm dev writes the gitignored +types/. Fresh checkouts need it first.

Where to go next

On this page