Naalya Handbook

Troubleshooting & Gotchas

The traps a new developer hits on the Naalya site — each one as a symptom, the why behind it, and the fix.

Every codebase has a handful of sharp edges that aren't bugs — they're deliberate choices, half-finished wiring, or stock files nobody deleted — and they cost you an afternoon precisely because nothing looks wrong. This page collects the ones on the Naalya site in one place, so when something behaves oddly you can scan for the symptom instead of spelunking.

The pattern below is always the same: symptom (what you see), why (the real cause), fix (what to do). Most of these are also called out on the page where they live — this is the single index that ties them together.

If something silently doesn't work, suspect config first

A recurring theme runs through almost every gotcha here: nothing validates your setup. No .env schema, no Node version pin, no type-checked GROQ results. So the failure mode is rarely a loud error — it's a blank section, a missing favicon, or a token where you didn't expect one. When in doubt, check the boring stuff (env, Node, the global CLI) before you debug the code.


Setup traps

These three bite on day one, before you've written a line of code. The full walkthrough is in Getting Started; here's the short version of what goes wrong.

command not found: chowbea-axios

Symptom. Every pnpm api:* script dies with command not found right after a clean pnpm install.

Why. The typed REST client is generated by a CLI called chowbea-axios that is installed globally — it is not in package.json, so pnpm install never puts it in node_modules/.bin. The one exception is api:generate:fetch, which shells out to a committed Node script and works offline.

Fix. Install it once, globally, then the scripts come alive:

terminal
pnpm add -g chowbea-axios

You don't need it to boot the site — the generated files under app/services/api/_generated/ are committed — but you do need it to regenerate them. The API Client covers the full workflow.

There is no .env.example

Symptom. You clone, look for a template to copy, and there isn't one. Fetches then fail at runtime with no clear cause.

Why. The repo ships no .env.example or .env.sample, and .env* is gitignored at both the root and inside sanity/. On top of that, createClient accepts undefined for any unset variable — so a missing var doesn't throw, it just produces failed fetches later.

Fix. Reconstruct .env by hand at the repo root. The minimum to connect to Sanity:

.env
VITE_SANITY_PROJECT_ID=hdefozzv
VITE_SANITY_DATASET=development
VITE_SANITY_API_VERSION=2024-01-01
VITE_SANITY_WRITE_TOKEN=<sanity token with write scope>

The full inventory of variables lives in The Stack, and Setup explains each Sanity value.

The dataset is 'development', not 'production'

A classic first-day trip-up: the default dataset is development. The Studio hardcodes projectId: "hdefozzv" and dataset: "development" in sanity/sanity.config.ts, and the frontend must match those exact values from env. Point at production and you'll connect to an empty (or wrong) dataset and wonder why every page is blank.


The two that look like bugs but aren't

These two are working as designed — but the design is surprising, so people file them as bugs.

The Sanity write token ships to the browser

Symptom. Open devtools on a built page, dig through the bundle, and there's a write-scoped Sanity token sitting in plain sight.

Why. Every VITE_* env var is inlined into the client bundle at build time — that's how Vite exposes them to browser code. The single @sanity/client in app/lib/sanity-client.ts carries VITE_SANITY_WRITE_TOKEN because the same client also performs writes (form submissions go through .create() / .patch()). So the read path drags a write token along for the ride, straight into the client.

Fix. Treat the token as already public. Don't grant it more scope than form mutations need, and flag this before production. The proper hardening is a split: a read-only client (no token) for the query layer, and a server-only mutation path that keeps the write token off the wire.

VITE_ vars are build-time — you can't override them at runtime

Because VITE_* values are baked into the bundle, you cannot change them on a running server. On Railway, pass VITE_SANITY_* as Docker build ARGs, and remember: changing any of them means a rebuild, not a restart. Setup has the Dockerfile ARG block.

Every detail page 404s after a refactor

Symptom. You touch a route loader and suddenly every blog post throws a 404 — even ones that clearly exist.

Why. The loader returns a real 404 by prefetching the document and then reading it synchronously back out of the cache. That only works if the prefetch was awaited first. Drop the await (or reorder the lines) and getQueryData reads an empty cache, so the guard fires on every request.

app/routes/blog-post.tsx
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 });

Fix. Keep the order prefetch → then read, and keep the await. It is non-negotiable. The full pattern is in Single Documents & Singletons.


Wiring that's half-finished

The next three are the most disorienting, because the code looks complete. It isn't — it's installed but not connected.

Toasts ignore your theme

Symptom. You flip the site to dark mode, but toast notifications (Sonner) keep rendering in the wrong theme.

Why. This one's a name collision with real consequences. The app's actual theme system is a custom Zustand store in app/components/providers/theme.provider.tsx, which also exports a hook named useTheme. But app/components/ui/sonner.tsx imports useTheme from the next-themes package instead. next-themes is in package.json, yet its provider is never mounted — so its useTheme always returns the "system" default, disconnected from the real store.

app/components/ui/sonner.tsx
import { useTheme } from "next-themes"; // wrong source — provider never mounted
const { theme = "system" } = useTheme();

Fix. Point the Toaster at the real store: import useTheme from @/components/providers/theme.provider instead. Treat next-themes as a leftover from the shadcn scaffold, not the app's theme engine. Styling & Theming describes the Zustand theme system.

Two hooks named useTheme — check the import path

When you see useTheme, read where it comes from. @/components/providers/theme.provider is the live one (Zustand, persisted to localStorage as naalya-theme). next-themes is dead wiring. The names are identical; the behavior is not.

Site settings get fetched twice

Symptom. Watching the network tab, you notice the siteSettings singleton is fetched two times on a fresh load.

Why. There are two independent readers of the same document. app/root.tsx fetches it directly — outside TanStack Query, no caching — purely to set the favicon. Then components like the navbar and _layout call the useSiteSettings hook, which fetches the same singleton again through the query module. Neither knows about the other, so the work is duplicated.

app/root.tsx
const siteSettings = await sanityClient.fetch<SiteSettingsDTO>(SITE_SETTINGS_QUERY);

Fix. Usually nothing — it's one tiny extra request and the data is small. If you're optimizing, the root loader could prefetch into the query cache and dehydrate it so the hook reads the warm copy instead of refetching. Know it's intentional, not a leak. The direct-fetch escape hatch is explained in Querying with GROQ.

The pre-commit hook does nothing

Symptom. You commit obviously unformatted code and the hook waves it through.

Why. pnpm install runs the prepare script (husky) and wires up git hooks — but the only hook is a no-op. .husky/pre-commit echoes two lines and exits clean. It runs no formatter, no linter, no tests.

.husky/pre-commit
echo "Running pre-commit checks..."
echo "Pre-commit checks passed."
exit 0

Fix. Don't rely on the hook to catch anything. Run Biome yourself before you push, and if you want real enforcement, replace the hook body with an actual check (e.g. pnpm biome check). Until then, "Pre-commit checks passed." is theater.


Files that lie to you

Two committed artifacts say things that aren't true for this project. Trust them and you'll go down the wrong path.

The README is the worst offender. It's the stock "Welcome to React Router!" template — it tells you to run npm install and npm run dev, and it never mentions pnpm, the global CLI, the Sanity Studio, or the codegen workflow. Treat package.json and this handbook as the source of truth, and use pnpm — never npm, yarn, or bun.

The other is the singleton doc: sanity/SINGLETON_PATTERN.md explains the why of singletons but lists only a few examples. The authoritative list is the exclusion array in sanity/sanity.config.ts (~75 types). When you need to know whether a type is a singleton, read the config, not the markdown — Single Documents & Singletons covers how that enforcement works.

DTOs drift silently — the cast hides it

The quietest gotcha of all: Sanity TypeGen is not wired up for the frontend, so a GROQ result comes back as any and the hook as-casts it to a hand-written DTO. Add a field to a projection and forget the DTO, and TypeScript stays happy while the field is undefined at runtime. Edit the projection and the DTO together, every time. See Project Structure.


The cheat sheet

When something's off, scan this first. Most rows are "by design," which is exactly why they fool you.

SymptomCauseFix
command not found: chowbea-axiosGlobal CLI, not in package.jsonpnpm add -g chowbea-axios
Fetches fail, no .env templateNo .env.example; unset vars are undefinedBuild .env by hand at root
Every page blank on first runPointed at production, not developmentMatch the Studio's hardcoded dataset
Write token visible in the bundleVITE_* inlined at build timeBy design — keep scope minimal, split clients to harden
Every detail page 404sgetQueryData ran before the awaited prefetchPrefetch first, then read
Toasts ignore the themeSonner reads next-themes, which is never mountedImport useTheme from the Zustand provider
siteSettings fetched twiceDirect fetch in root.tsx + the hookBy design; prefetch + dehydrate to dedupe
Unformatted code commits clean.husky/pre-commit is a no-opRun Biome manually; replace the hook body

Where to go next

On this page