Routing & Pages
How React Router v7 framework mode routes the site - one manifest, the route-module contract, SSR, dynamic params, and typegen.
A URL on this site exists for exactly one reason: something listed it in app/routes.ts. There is no file-based routing here — dropping a file into app/routes/ does literally nothing until you wire it into that manifest. Once you internalize that one fact, the whole routing model stops being magic and becomes a short list of moving parts you can hold in your head.
The site runs React Router v7 in framework mode. That means the @react-router/dev Vite plugin owns your routes: it compiles the manifest, runs server-side rendering, and generates per-route types. Each page is a plain ES module that exports a few well-known names — a loader, a meta, a default component — and React Router calls them at the right moments. Data is prefetched on the server and hydrated on the client through TanStack Query, so crawlers get full HTML and users never watch a spinner for content the server already had.
Framework mode, not library mode
You will not find createBrowserRouter anywhere. That is React Router used as a plain client library — a different thing. Here the framework compiles routes from app/routes.ts, server-renders them, and writes types for you. The three files that define the whole system are react-router.config.ts (config), app/routes.ts (the route list), and app/root.tsx (the HTML document shell).
The manifest is the single source of truth
app/routes.ts default-exports one array describing every URL in the app. You build that array from four helpers, and the whole thing is checked with satisfies RouteConfig so a typo in a path is a compile error, not a 404 in production.
The helpers read almost like English. Here is the shape of the real file, trimmed to the parts that teach:
import { index, layout, prefix, route, type RouteConfig } from "@react-router/dev/routes";
export default [
layout("routes/_layout.tsx", [
index("routes/landing.tsx"), // "/"
route("/about", "routes/about.tsx"),
route("/blog/:postSlug", "routes/blog-post.tsx"),
/* ...the rest of the site, plus 8 legacy redirect routes */
]),
...prefix("naalya-ai", [
index("routes/naalya-ai/index.tsx"), // "/naalya-ai"
route("/:bot", "routes/naalya-ai/chat.tsx"), // "/naalya-ai/:bot"
]),
] satisfies RouteConfig;Four helpers, four jobs. index(file) is the / of its parent. route(path, file) maps a URL — including dynamic ones like :postSlug — to a module. layout(file, children) adds a shared UI wrapper with no URL of its own. And prefix(path, children), spread with ..., prepends a URL segment to a group without wrapping it in any layout.
That last contrast is the one to actually understand, because it shapes how the site looks:
Layout adds chrome; prefix adds a URL segment
layout(...) wraps its children in shared UI — here the Navbar, Footer, and Snowfall — but contributes nothing to the URL. prefix(...) does the opposite: it adds a URL segment but no shared UI. The naalya-ai block is a sibling of the layout block, spread in with ...prefix(...). That is deliberate: the AI pages live at the top level and render without the site Navbar and Footer, because they supply their own full-screen chrome.
So /about renders inside the site shell, while /naalya-ai/ada renders bare. Same manifest, two different worlds — decided entirely by which helper a route sits under.
Filenames are labels, not routes
The two gotchas that bite newcomers live together. One: adding a file under app/routes/ has zero effect until you register it with route(...) or index(...). Two: the filename never produces the URL. campuses/lugazi.campus.tsx does not become /campuses/lugazi because of its name — the URL comes entirely from the path string in the manifest. The dotted *.campus.tsx is just a naming convention to group files in the editor.
The route-module contract
A route module is an ordinary ES module that exports specific names React Router knows to look for. The framework recognizes a whole menu of them — loader, action, clientLoader, meta, links, headers, ErrorBoundary, HydrateFallback, and more — but this codebase deliberately uses a small slice. In practice every page has a default component; most have a loader and a meta; and links plus ErrorBoundary live in exactly one file, root.tsx.
The two biggest absences are worth stating up front, because assuming they exist will send you down the wrong path. There are no actions and no clientLoaders here: data writes never go through action or clientAction, and every loader is server-only. Form submissions run through TanStack Query mutation hooks (e.g. useCreateWebsiteForm in contact-us.tsx, with a Sanity backup), and the AI chat goes through the AI SDK's useChat transport. Do not reach for a route action — follow the existing mutation-hook pattern, covered on the Forms page.
loader — server-only data, three flavors
Loaders run only on the server. The canonical content-page loader doesn't return your data at all — it builds a per-request QueryClient, prefetches everything the page needs in parallel, and returns the dehydrated cache:
export async function loader() {
const queryClient = getQueryClient();
await Promise.all([
queryClient.prefetchQuery(LandingOptions.landingData()),
queryClient.prefetchQuery(CampusListOptions.campusListData()),
/* ...the other prefetches this page needs */
]);
return { dehydratedState: dehydrate(queryClient) };
}Returning the dehydrated cache instead of a flat DTO is the single most important idea on this page — it changes how meta and your component read data, and the SSR section explains why.
A loader on a dynamic route does one extra thing: it turns a missing record into a real 404. It prefetches, reads the result straight back out of the cache, and throws a Response if nothing came back:
export async function loader({ params }: Route.LoaderArgs) {
const queryClient = getQueryClient();
await queryClient.prefetchQuery(BlogPostOptions.blogPost(params.postSlug));
const post = queryClient.getQueryData(["blog-post", params.postSlug]);
if (!post) throw new Response("Post not found", { status: 404 });
return { slug: params.postSlug, dehydratedState: dehydrate(queryClient) };
}That params.postSlug is fully typed as string thanks to typegen. The thrown Response bubbles up to the root ErrorBoundary, which renders the styled 404. (This exact "await prefetch, then read the cache" sequence is unpacked further in Single Documents & Singletons.)
The third flavor is the tiniest: a redirect loader. Eight legacy URLs share this one shape — a loader that returns redirect(...) and a component that renders nothing:
import { redirect } from "react-router";
export async function loader() {
return redirect("/admissions", { status: 301 });
}
export default function AdmissionRedirect() {
return null;
}There is also one special loader in app/root.tsx. It is the only place that calls the Sanity client directly (no TanStack Query), fetching the global site settings the document shell needs for things like the favicon.
meta — SEO tags, including the cache dig
meta returns an array of descriptors — title, description, Open Graph, Twitter tags. It can build them from a static string, from typed params (the chat route titles itself from params.bot), or from loader data. That last source has a twist you have to know about:
export function meta({ data }: Route.MetaArgs) {
const landingData = data?.dehydratedState?.queries?.find(
(q) => JSON.stringify(q.queryKey) === JSON.stringify(["page", "landing"]),
)?.state?.data as { seoTitle?: string; seoDescription?: string } | undefined;
const title = landingData?.seoTitle ?? "Naalya Schools - Nurturing Excellence";
/* ...build the descriptor array from title */
}Because the loader returns a dehydrated cache and not a flat object, meta cannot just read data.seoTitle — it has to search data.dehydratedState.queries for the right query by its queryKey.
meta() matches the queryKey by exact string
The lookup compares JSON.stringify(queryKey). If the string does not match exactly the key your query options use, the find silently returns undefined and SEO falls back to the hardcoded default — no error, just wrong tags. When you copy this pattern to a new page, double-check the queryKey. (Separately, blog-post.tsx both exports meta() and renders raw <title>/<meta> JSX in its component body — it double-sets metadata. Pick one approach when you touch that file.)
The default component reads loaderData, not a hook
Components receive their loader's return through a typed loaderData prop. useLoaderData() is never imported in this app. A content page is usually a thin shell that hydrates the cache and hands off to an inner component:
const LandingPage = ({ loaderData }: Route.ComponentProps) => (
<HydrationBoundary state={loaderData.dehydratedState}>
<LandingPageContent />
</HydrationBoundary>
);
export default LandingPage;loaderData is typed straight from this route's own loader return — change the loader's shape and the prop type follows automatically. Mixing in useLoaderData() would be untyped against the route and inconsistent, so stick with the prop.
Layouts, the document shell, and how 404s happen
The site has two layers of wrapping above your page, and they do different jobs.
The pathless layout("routes/_layout.tsx", ...) is the site chrome: it renders the Navbar, a banner, an <Outlet /> where the matched page goes, the Footer, and the optional Snowfall. One detail surprises people — _layout.tsx does not export a loader. It reads site settings through the useSiteSettings() TanStack hook, which returns null while loading, so layout-level data is fetched client-side, not SSR'd through the layout route. (Site settings end up fetched twice — once in the root loader for the favicon, once by this hook — into separate caches.)
Above the layout sits app/root.tsx, the ultimate parent of every route. Its Layout({ children }) renders the actual <html> document — <Meta />, <Links />, <Scripts />, <ScrollRestoration />, analytics, the CMS-driven favicon, and the whole provider tree. Its ErrorBoundary is where the custom 404 lives:
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) {
// styled 404 page with a useNavigate() countdown back to "/"
}
/* ...dev-only error detail for everything else */
}There is no catch-all splat route — the manifest has no *. A 404 arises one of two ways: a loader throws new Response("...", { status: 404 }) (as blog-post.tsx does), or the router simply can't match the URL. Both land in the root ErrorBoundary, are recognized with isRouteErrorResponse(error), and render the same styled 404 with a countdown redirect to /.
Putting the layers together, a request to /blog/some-post nests like this — and /naalya-ai/ada skips the middle layer entirely because it lives under prefix, not layout:
root.tsx (<html> + <Meta/> + <Links/> + providers + <Scripts/>)
└─ App() → <Outlet/>
└─ _layout.tsx (Navbar + banner + <Outlet/> + Footer + Snowfall)
└─ blog-post.tsx (the page, receives loaderData)SSR: the dehydrate/hydrate handshake
SSR is on globally — react-router.config.ts is just { ssr: true } satisfies Config. But the data strategy is not React Router's "loader returns data, component reads it." Instead it is TanStack Query prefetch on the server, dehydrate, then hydrate on the client, so the rendered HTML already contains the content and the browser never re-fetches it on first paint.
The handshake has four beats: the loader fills a fresh per-request cache and dehydrate()s it; React Router ships that serialized cache with the HTML; the route component's <HydrationBoundary> rehydrates it into the browser's QueryClient; and each inner component's use-* hook reads from a cache that is already warm, so its loading branch never runs. The split that makes this safe — a fresh getQueryClient() per server request so one user's data never leaks into another's HTML, and a singleton in the browser so the cache survives client navigations — is taught step by step on How It's Built.
If you only remember one thing: loaderData is { dehydratedState }, not your DTO. Your component reads the real content through TanStack Query hooks behind a HydrationBoundary, and that is exactly why meta() has to dig through the cache by queryKey. The full data pipeline lives in Querying with GROQ.
Dynamic params and navigation
Two routes carry dynamic segments: /blog/:postSlug and /naalya-ai/:bot. Both params are typed string. Read them from the loader's typed params when there is a loader, or from useParams() in a component that has none — the chat route does the latter:
// In a loader or meta:
const slug = params.postSlug; // string
// In a loader-less component:
const { bot = "ada" } = useParams() as { bot: BotType };For navigation, the dominant pattern is <Link to="..." viewTransition> — nearly every internal link opts into the View Transitions API. Imperative jumps use useNavigate() (the 404 countdown, the AI pages), <Navigate /> does a declarative redirect mid-render, and redirect() handles the server-side 301s. One choice here is worth flagging because it is fragile:
Active-link styling is hand-rolled — NavLink is never used
NavLink and its isActive render-prop appear nowhere. Active styling is computed by hand in navbar.tsx from useLocation().pathname using string equality and .includes() (e.g. pathname.includes("/campuses/namugongo")). That .includes() can produce false-positive substring matches. When you add an active link, match the existing manual style and watch for substring collisions rather than reaching for NavLink.
Typegen and the +types imports
End-to-end type safety comes from react-router typegen, which generates a mirror tree of type modules under .react-router/types/. Every route imports Route from a sibling-looking path that does not physically exist next to the file:
import type { Route } from "./+types/blog-post";That resolves because tsconfig.json sets rootDirs: [".", "./.react-router/types"], which overlays the generated tree onto your source tree. So ./+types/blog-post resolves to the generated .react-router/types/app/routes/+types/blog-post.ts. That generated module is what gives you Route.LoaderArgs (typed params), Route.ComponentProps (typed loaderData), Route.MetaArgs, and the root-only Route.LinksFunction and Route.ErrorBoundaryProps.
Typegen runs as part of pnpm typecheck (react-router typegen && tsc) and implicitly whenever you run dev or build. That timing is the source of the one gotcha worth burning in:
Run typegen after any routing change — and first on a fresh checkout
.react-router/ is gitignored, so it does not exist on a clean clone or in CI, and any edit to app/routes.ts (or a brand-new route file) leaves it stale. Until you run react-router typegen — or pnpm dev / pnpm build / pnpm typecheck, which all trigger it — every ./+types/... import reports a missing module. If your editor flags those imports as not found, that is the fix. Separately, the production build sets NODE_OPTIONS=--max_old_space_size=4096 because the bundle is large — a plain react-router build can run out of heap.
One generated detail is genuinely useful to read. Each module carries a Matches tuple — the route's ancestry chain — and it differs by where the route lives, which makes it the type-level proof of the layout-vs-prefix split:
type Matches = [
{ id: "root"; module: typeof import("../../root.js") },
{ id: "routes/_layout"; module: typeof import("../_layout.js") },
{ id: "routes/blog-post"; module: typeof import("../blog-post.js") },
];The blog post's chain runs root → _layout → blog-post. The chat route's chain is just root → chat — no _layout, because it sits under prefix. That omission is the compiler confirming the AI pages skip the site chrome.
Adding a route, in one breath
The full, copy-paste walkthrough lives on its own page — see Adding a Page. The mental shortcut for all of it: add a line to app/routes.ts, create the matching module, run typegen. A static page is a route(...) inside layout(...); a data page adds a query module plus a prefetch/dehydrate loader; a dynamic route uses :slug and reads params.slug; a redirect is a redirect() loader; and a page that should escape the site chrome goes in its own ...prefix(...) block.
Where to go next
Adding a Page
The step-by-step recipes for static pages, SSR data, dynamic routes, redirects, and prefix groups.
Querying with GROQ
The query-options + use-hook pattern that loaders prefetch and components hydrate.
Single Documents & Singletons
The await-prefetch-then-read pattern behind the 404 loader, and the root-loader path.
Project Structure
How app/ is organized, the per-page query trio, and the @/* path alias.
The Stack
React Router v7, TanStack Query, Sanity, and the rest of the toolchain.
Components
The Navbar, the Outlet-driven layout, and the UI building blocks pages render.