Project Structure
A tour of the app/ folder — how it's organized by role, the @/ import alias, and the per-feature query-module convention.
Open the repo for the first time and the question is always the same: where does my code go? The Naalya site answers that with one firm rule — the app/ folder is organized by what code does, not by which page it serves. A button lives with the buttons; a data-fetch lives with the data-fetches. The page that stitches them together is just a route that imports both.
Get that idea in your head and the rest of this page is just naming the buckets. Put a file in the wrong one — or name it the wrong way — and you'll feel it in code review, because every contributor is reading the tree the same way you are.
One product, two surfaces
The repository root is the React Router v7 frontend (website-frontend), and it lives next to a Sanity Studio in sanity/. All the frontend source you'll touch is under app/. When a doc mentions an apps/studio or apps/app layout, ignore it — that's a stale plan. Trust the filesystem: app/ and sanity/ sit at the root.
The app/ folder, by role
Everything the frontend ships lives under app/, and each top-level folder owns exactly one job. You don't need to memorize the whole tree — you need the handful of folders you'll reach for daily and a clear sense of what each is for.
| Folder | What lives here |
|---|---|
app/routes/ | React Router route modules — the actual pages and their loaders. |
app/components/ | All React UI, split into role buckets (primitives, sections, navigation…). |
app/queries/ | The data layer: one folder per CMS page or section (the convention below). |
app/lib/ | Stateful singletons and configured integrations: sanity-client.ts, query-client.ts, the cn merger in utils.ts. |
app/helpers/ | Pure input-to-output functions: formatters, the logger, image optimizers. |
app/hooks/ | Reusable React hooks (use-mobile.ts, use-page-section.tsx). |
app/stores/ | Zustand stores for client UI preferences. |
app/services/api/ | The code-generated, typed REST client (separate from Sanity). |
The two that trip people up are lib/ and helpers/, because both sound like "miscellaneous." They aren't interchangeable.
lib/ holds state; helpers/ stays pure
app/lib/ is for things that new something or read config — a client instance, the analytics wrapper, the cn class merger. app/helpers/ is for pure functions with no app state, like format-file-size.ts. The quick test: if it touches env vars or constructs a singleton, it's lib/. Otherwise it's a helper/.
A few special files live at the app/ root itself: root.tsx is the global layout (site-settings loader, providers, the 404 boundary), routes.ts is the config-based route manifest, and app.css is the single Tailwind v4 entry. Those three are the spine of the app — Routing & Pages walks through how they fit together.
The app/components/ tree deserves its own treatment — it splits into shadcn primitives, bespoke UI, and CMS-driven sections, each in its own bucket. Components covers that taxonomy; don't flatten those folders.
The @/ import alias
There is exactly one path alias in this codebase, and learning it once saves you from ever counting ../ segments again. @/ maps to app/. So @/lib/utils is app/lib/utils, @/components/ui/button is app/components/ui/button, full stop.
It's declared in tsconfig.json and resolved at build time by the vite-tsconfig-paths plugin:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./app/*"]
}
}
}The rule of thumb is simple: always import across folders with @/. A relative ../ is tolerated only for an immediate sibling — a file reaching into the same folder it lives in. Anything that climbs out of its folder should use the alias. In practice the alias wins overwhelmingly: it's used in hundreds of imports, while only a handful of files reach with ../.
// Good — alias for anything cross-folder
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { LandingOptions } from "@/queries/landing/landing.options";
// Tolerated — an immediate sibling only
import { ThemeProvider } from "./theme.provider";
// Avoid — climbing out of the folder with relative paths
import { cn } from "../../lib/utils";You'll never hand-sort these. Biome runs organizeImports on save and again on commit, so write them in any order and let the tooling group them. The one helper you'll import most is cn from @/lib/utils — the canonical Tailwind class merger that every conditional className flows through.
The shadcn CLI knows the alias too
components.json mirrors the @/ map ("ui": "@/components/ui", "utils": "@/lib/utils", and so on), so when you generate a shadcn primitive it lands with correct alias imports automatically — no manual fix-up. See Components for the generation flow.
The query-module convention
This is the single most important structural rule in the codebase, so slow down here. Every CMS page or section gets its own folder under app/queries/<feature>/, and inside that folder the same four-part shape repeats every single time. The pattern recurs across roughly twenty feature folders — once you can read one, you can read them all.
The shape is a deliberate pipeline: the query describes what to fetch, the options describe how to fetch and cache it, the hook is how a component consumes it, and the DTO is what shape comes back.
app/queries/landing/
├── landing.query.ts # the GROQ string (what to fetch)
├── landing.options.ts # queryOptions + cache preset (how to fetch)
├── use-landing.tsx # the useQuery hook (how to consume)
└── interfaces/
├── landing.dto.ts # hand-written result type (what comes back)
└── index.ts # barrel re-exportRead the folder top to bottom and you're reading the data flow in order. Let's walk each file.
Step 1: the query — what to fetch
The .query.ts file holds the GROQ, wrapped in defineQuery and exported in SCREAMING_SNAKE_CASE ending in _QUERY. The house style is to project every field explicitly (never spread ...), dereference references with ->, and give each segment its own line so diffs stay readable.
import { defineQuery } from "groq";
export const LANDING_PAGE_QUERY = defineQuery(`*[
_type == "landingPage" && _id == "landingPage"
][0]{
_id, title, seoTitle, seoDescription,
hero->{ heading, highlightedText, description },
stats->{ stats[]{ value, label, icon } }
/* ...the rest of the sections... */
}`);The full projection lives in the real file — you rarely memorize it. What matters is the convention. Querying with GROQ covers how to actually write these filters.
Step 2: the options — how to fetch and cache
The .options.ts file is a class with static methods, each returning a TanStack queryOptions object. The queryFn calls sanityClient.fetch(...), and — this is the part people get wrong — the cache policy is spread from a shared preset, never hand-rolled per query.
import { queryOptions } from "@tanstack/react-query";
import sanityClient from "@/lib/sanity-client";
import { cache_24_hours } from "../cache-info";
import { LANDING_PAGE_QUERY } from "./landing.query";
export class LandingOptions {
static landingData() {
return queryOptions({
queryKey: ["page", "landing"],
queryFn: async () => await sanityClient.fetch(LANDING_PAGE_QUERY),
...cache_24_hours,
});
}
}Those presets live in app/queries/cache-info.tsx — named durations from cache_1_minute up to cache_30_days. Each one sets both staleTime and gcTime together, so you pick the lifetime and spread it. Don't write staleTime/gcTime by hand.
Step 3: the hook — how a component consumes it
The use-<feature>.tsx file is a thin wrapper around useQuery. Its only real jobs are to feed in the options and to cast the result to the hand-written DTO, then re-export refetch under a descriptive name.
import { useQuery } from "@tanstack/react-query";
import type { LandingPageData } from "./interfaces/landing.dto";
import { LandingOptions } from "./landing.options";
const useLandingPageData = () => {
const { data, refetch, ...rest } = useQuery(LandingOptions.landingData());
return {
landingPageData: data as LandingPageData,
refetchLandingPageData: refetch,
...rest,
};
};
export { useLandingPageData };A component never imports the query or the options — it imports this hook and gets typed data back. That's the whole point of the layering.
Step 4: the DTO — what comes back
The interfaces/ folder holds the hand-written type for the result, plus a barrel index.ts. This is where the one real footgun of the whole pattern lives.
DTOs are hand-written and will drift
Sanity TypeGen is not wired up for the frontend. The DTOs in interfaces/*.dto.ts are written by hand, and the hook as-casts the query result to them. So if you add a field to the GROQ projection, you must add it to the DTO too — TypeScript won't catch the mismatch, because the cast tells it to trust you. Forget, and the field is silently undefined at runtime.
The fastest way to start a new feature is to copy app/queries/landing/ and rename — you inherit the correct shape for free. Adding a Page walks the full end-to-end, including wiring the module into a route loader.
New query folders are kebab-case
Most folders under app/queries/ are kebab-case (academic-overview, blog-post), but a few legacy ones drifted to camelCase (learningEnvironment). Don't copy the drift — name new folders kebab-case.
The conventions that bind it all
Two naming rules run through the entire app/ tree, and they're enforced in review:
- Files are kebab-case, everywhere. There are zero PascalCase filenames in
app/— components included (section-pill.tsx, notSectionPill.tsx). - Role suffixes disambiguate multi-purpose files:
*.query.ts,*.options.ts,*.dto.ts,*.provider.tsx,*.client.ts. The suffix tells the next reader what kind of file it is before they open it.
That's the map. The folder tells you a file's role, the suffix tells you its kind, and the @/ alias lets you reach any of it from anywhere. Internalize those three and you'll put new code exactly where the next contributor expects to find it.
Where to go next
Getting Started
Install, set env vars, and run the dev server.
Routing & Pages
The route manifest, loaders, and SSR hydration.
Adding a Page
Build a new CMS page and its query module end to end.
Querying with GROQ
Write the GROQ that powers the query module.
Components
The component buckets and the shadcn generation flow.
The API Client
The other data source: the generated REST client.