The Stack
A guided tour of the libraries the school website runs on — what each one does, why it's here, and how they fit together.
Every line in package.json is a decision someone made. This page walks you through the ones that matter — not as an inventory, but as a story: which tool owns which job, why it was picked over the alternatives, and how the pieces snap together into one app. Read it once and the codebase stops looking like a pile of dependencies and starts looking like a system.
The mental model to hold onto: the site is a React Router app that renders on the server, pulls its content from Sanity and its application data from a typed API client, and leans on shadcn/Radix for UI. Everything below hangs off that sentence.
One repo, two separate installs
This is a pnpm monorepo with two independent packages — the website frontend at the root (website-frontend) and the Sanity Studio in sanity/ (naalya-website). But pnpm-workspace.yaml declares no packages: field, so a root pnpm install does not install the Studio. You have to cd sanity && pnpm install separately. See Setup for the Studio side.
Framework and rendering
At the core sits React 19, rendered server-side through React Router 7 in framework mode. That phrase is the most important one on the page. "Framework mode" means React Router isn't just matching URLs — it owns loaders, actions, and SSR, the same job Remix used to do. Every page you'll write starts life on the server, with data already fetched, before the browser ever sees it.
One consequence shapes every import you write: everything comes from react-router directly, never react-router-dom. The Node adapter and production server (@react-router/node, @react-router/serve) round out the runtime, and @react-router/dev supplies the Vite plugin and typegen.
SSR is on by default, and the switch is a single line:
import type { Config } from "@react-router/dev/config";
export default {
ssr: true, // flip to false for SPA mode
} satisfies Config;isbot is installed but doing nothing
isbot ships in the React Router template for bot detection inside a custom entry.server. This project has no custom entry files, so it uses React Router's built-ins and isbot is never imported. Don't assume it's load-bearing — it's dead weight you can ignore.
The build is Vite 7, and the plugin order in vite.config.ts is not arbitrary — tailwindcss() must run before reactRouter(), with devtools() registered first. Two dependencies also have to be force-bundled for SSR because they ship CSS imports the server build can't externalize:
export default defineConfig({
plugins: [devtools(), tailwindcss(), reactRouter(), tsconfigPaths()],
ssr: {
// these ship CSS/ESM that breaks if left external
noExternal: ["streamdown", "react-syntax-highlighter"],
},
});If you ever add a dependency that imports CSS at module load and the SSR build crashes, ssr.noExternal is the first place to look. The full routing model lives in Routing & Pages.
Styling and UI
Styling runs on Tailwind CSS v4 with zero JavaScript config. There is no tailwind.config.js anywhere in the repo — and that surprises people, so it's worth saying twice. Every theme token, plugin, and custom variant lives inside app/app.css. The CSS file is the config:
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@theme {
--font-poppins: "Poppins", sans-serif;
/* + color tokens in oklch, custom keyframes */
}Class composition always flows through the cn() helper — clsx for conditionals, tailwind-merge to resolve conflicts so the last utility wins. Variants for components come from class-variance-authority. If you're hunting for where a color or font is defined, open app.css, not a JS file that doesn't exist. The full token system and dark-mode mechanism are in Styling & Theming.
The component layer is shadcn/ui (new-york style, neutral base) sitting on top of a near-complete Radix UI primitive set. The split is the whole point: Radix supplies the headless behaviour — focus traps, keyboard nav, ARIA — and shadcn copies styled, editable component files into app/components/ui/ that you own outright. Adding one is a single command that reads components.json and wires cn for you:
pnpm dlx shadcn@latest add <component>The Radix package is usually already there
Twenty-six @radix-ui/react-* packages are installed and every one is imported. When you add a shadcn component its Radix dependency is almost always present already — only install a new Radix package if the CLI tells you one is missing.
Three icon sets coexist, and the distinction matters: @tabler/icons-react is the primary set for app content and the target of the CMS icon-mapper, lucide-react is what shadcn's generated components use internally, and iconsax-react is a niche extra. Beyond that, a fleet of focused libraries each own one visual job — embla-carousel for carousels, sonner for toasts, vaul for drawers, cmdk for the command menu, recharts for charts, canvas-confetti and react-snowfall for flourishes. Reach for the existing one before adding a new dependency; Components covers the conventions.
next-themes is a decoy — use the zustand store
sonner.tsx imports useTheme from next-themes, but the app never mounts its ThemeProvider. Real theming is a custom zustand store (theme.provider.tsx) that toggles .dark on <html>. So useTheme always returns the "system" fallback. For any new theming work, use the zustand store — not next-themes.
For animation, standardize on motion, imported as motion/react (used in 22 files). Both motion and framer-motion are installed at the same major version — they're separate packages, so it's easy to grab the wrong one. framer-motion appears in exactly one file and should be treated as legacy.
Data and state
The site has two kinds of state and one rule: server state lives in TanStack Query, client state lives in zustand, and URL state lives in nuqs. Keep them in their lanes and the data flow stays predictable.
Server state is the interesting one because of SSR. A single shared QueryClient would leak one user's cache to the next request, so the client is created fresh per request on the server and reused as a singleton in the browser — always obtained through a factory, never new-ed in a component:
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
if (typeof window === "undefined") {
return makeQueryClient(); // fresh per request on the server
}
browserQueryClient ??= makeQueryClient(); // singleton in the browser
return browserQueryClient;
}Never instantiate QueryClient inline
Calling new QueryClient() inside a component breaks the SSR contract and can leak cache across users. Always go through getQueryClient(). This is the single most important rule in the data layer.
Client state — theme, settings, feature toggles — is zustand with the persist middleware, and persisted keys are namespaced naalya-* (naalya-theme, naalya-settings-storage) so they're easy to spot in localStorage. The provider stack assembles in a fixed order (NuqsProvider > QueryProvider > PostHogProvider > ThemeProvider); that order is deliberate, not incidental.
Forms and validation
Forms are react-hook-form for state, Zod for the schema, and @hookform/resolvers as the bridge between them. RHF keeps re-renders cheap by tracking fields outside React state; Zod defines the shape once and validates against it. Specialized inputs fill the gaps — input-otp for verification codes, react-phone-number-input for international numbers — and everything funnels through shadcn's form.tsx.
This is Zod v4
The project runs Zod v4, which has real API differences from v3. Plenty of tutorials online still show v3 syntax — check the current Zod docs before copying an example, or you'll chase confusing type errors. Forms shows the project's own patterns.
Content: Sanity CMS
Page content — the words, images, and structured sections an editor manages — comes from Sanity (project hdefozzv, dataset development). The flow is consistent everywhere: queries are authored with groq's defineQuery, prefetched in React Router loaders, then hydrated on the client by TanStack Query. That's the same SSR handoff you saw above, applied to CMS data.
The supporting cast is small and each part has a clear job: @sanity/client reads and writes, @sanity/image-url builds CDN transform URLs, @portabletext/react renders rich-text bodies, and @sanity/visual-editing powers the in-context Presentation overlay.
import { defineQuery } from "groq";
export const SITE_SETTINGS_QUERY = defineQuery(
`*[_type == "siteSettings"][0]{ siteName, /* ... */ }`,
);A write-capable Sanity token ships to the browser
sanity-client.ts reads VITE_SANITY_WRITE_TOKEN through import.meta.env, which Vite inlines into the client bundle — so a write-capable token reaches the browser to power client-side mutations. This is intentional, but the token's scope must be limited to exactly what those mutations need. Treat it as a security boundary when rotating.
GROQ authoring, the content model, pagination, and the singleton pattern are all documented across the Sanity CMS section.
The application API client
Content is one thing; application data — students, admissions, the things behind auth — is another. That comes from a NestJS-style backend, the "Naalya Schools API", and the frontend talks to it through a fully type-safe axios client generated from the live OpenAPI spec into app/services/api/.
The key property: the generated client returns a Result<T> shaped { data, error } and never throws. You branch on error instead of wrapping every call in try/catch:
import { api } from "@/services/api/api.client";
const { data, error } = await api.get("/students/{id}", {
params: { path: { id } },
});
// or the semantic, operationId-named form:
const result = await api.op.getStudentById({ id });Regeneration is driven by chowbea-axios — point api.config.toml at the swagger endpoint and run pnpm api:generate (or pnpm api:watch to poll, or pnpm dev:all to run the watcher alongside Vite). The _generated/ files are overwritten every run; the wrapper files around them are generated once and then owned by the team, so they're safe to hand-edit.
chowbea-axios is a global CLI — not in the lockfile
chowbea-axios drives every api:* script but is not in dependencies, the lockfile, or node_modules — it's installed globally. A fresh clone cannot regenerate the client until you install it globally (or invoke via pnpm dlx), or pnpm api:generate fails with "command not found".
The whole client — interceptors, the Result shape, regeneration — gets the full treatment in The API Client.
AI features
The Naalya AI chat is built on the Vercel AI SDK, streaming responses from backend bot endpoints. @ai-sdk/react supplies the useChat hook (one per bot), ai provides the transport and message types, and a few helpers polish the experience — streamdown renders streaming markdown, tokenlens turns token usage into a USD cost, and use-stick-to-bottom keeps the conversation scrolled to the latest message.
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
const ada = useChat({
transport: new DefaultChatTransport({ api: `${BASE_URL}/ada` }),
});The AI SDK packages are exact-pinned on purpose
@ai-sdk/react and ai carry no caret — they're pinned to exact versions because the AI SDK ships frequent breaking changes. When you bump other dependencies, leave these alone; don't widen them to ^.
Tooling and quality
The frontend uses Biome and only Biome — no ESLint, no Prettier — with biome.jsonc as the single source of truth and the ultracite preset on top. (The Sanity Studio is the exception: it still runs ESLint + Prettier.) Type-checking pairs react-router typegen with tsc. The two commands you run before pushing:
pnpm dlx ultracite fix # lint + format (what lint-staged is meant to run)
pnpm typecheck # react-router typegen && tscThe pre-commit hook is a no-op
Husky and lint-staged are configured, but the actual .husky/pre-commit hook only echos and exits — nothing is formatted or linted on commit. Running ultracite fix and typecheck yourself before pushing isn't optional housekeeping; it's the only thing standing between you and unformatted code in main.
Analytics goes through PostHog via typed event helpers in app/lib/analytics.ts (never raw posthog.capture at the call site), and logging goes through a shared pino logger. One trap worth knowing: react-ga4 is installed but unused — live Google Analytics runs from an inline gtag script in root.tsx with a hardcoded measurement ID, so the VITE_PUBLIC_GA4_MEASUREMENT_ID env var is effectively ignored.
Configuration and key versions
One rule governs all configuration: every runtime value is VITE_-prefixed and inlined at build time. There's no server-side runtime env reading. Change an API URL or a Sanity dataset and you must rebuild — passing each value as a Docker --build-arg, since the Dockerfile lists them all as ARGs. A missing VITE_* silently becomes undefined. There's no .env.example; the Dockerfile's ARG list is the contract. Getting Started walks through configuring them.
Most dependencies float on a caret, but a handful are pinned on purpose. If a line in package.json has no ^, it was pinned deliberately — bump it intentionally, in its own commit:
| Pinned dependency | Why it's locked |
|---|---|
@ai-sdk/react, ai | AI SDK ships frequent breaking changes |
recharts | Charting API stability |
@biomejs/biome, ultracite | Lint output must be reproducible |
Match Node to the Dockerfile for CI and containers
Production pins node:22-alpine. Local machines often run a newer Node (24.x) and it works fine, but CI and containers should match the Dockerfile. The build also raises the V8 heap to 4 GB — containers with under 4 GB of memory can OOM during pnpm build.
Where to go next
How It's Built
The big-picture architecture these libraries assemble into.
Getting Started
Clone, configure env, and run both packages locally.
The API Client
The generated, type-safe axios client in depth.
Styling & Theming
Tailwind v4 with zero JS config, tokens, and dark mode.
Components
shadcn/ui, Radix primitives, and building new UI.
Sanity CMS
The content model, GROQ queries, and the Studio.