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:
pnpm add -g chowbea-axiosYou 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:
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.
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.
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.
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.
echo "Running pre-commit checks..."
echo "Pre-commit checks passed."
exit 0Fix. 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.
| Symptom | Cause | Fix |
|---|---|---|
command not found: chowbea-axios | Global CLI, not in package.json | pnpm add -g chowbea-axios |
Fetches fail, no .env template | No .env.example; unset vars are undefined | Build .env by hand at root |
| Every page blank on first run | Pointed at production, not development | Match the Studio's hardcoded dataset |
| Write token visible in the bundle | VITE_* inlined at build time | By design — keep scope minimal, split clients to harden |
| Every detail page 404s | getQueryData ran before the awaited prefetch | Prefetch first, then read |
| Toasts ignore the theme | Sonner reads next-themes, which is never mounted | Import useTheme from the Zustand provider |
siteSettings fetched twice | Direct fetch in root.tsx + the hook | By design; prefetch + dehydrate to dedupe |
| Unformatted code commits clean | .husky/pre-commit is a no-op | Run Biome manually; replace the hook body |
Where to go next
Getting Started
The setup traps in context — pnpm, the global CLI, and building your .env.
Setup
The Sanity client, the write-token exposure, and the build-time env gotcha.
Querying with GROQ
The read path, the direct-fetch escape hatch, and why DTOs drift.
Single Documents & Singletons
The 404-in-the-loader pattern and where the real singleton list lives.