Naalya Handbook
Sanity CMS

Querying with GROQ

The read path for Sanity content — the shared client, the query-module convention, GROQ projections, and prefetch-on-server hydrate-on-client.

Every piece of CMS content on the site — the homepage hero, a blog post, the global site settings — reaches React the same way. There is one road in, and once you can picture it, you stop guessing where code goes. Data flows from a single Sanity client, through a small per-domain query module, into the route loader on the server, and finally into the component, which reads it straight out of a warm cache.

The guiding idea is one sentence worth memorizing: prefetch on the server, hydrate on the client. The route loader runs your GROQ during SSR and ships the results inside the HTML; the component then reads those same results out of the TanStack Query cache. No loading spinner, no client-side waterfall, and crawlers get fully-rendered content.

Here is the whole pipeline at a glance — five layers, each with one job, and each file path you'll reach for:

the read path
app/lib/sanity-client.ts          one shared @sanity/client


app/queries/<domain>/             GROQ string → queryOptions → use* hook → DTO


route loader()                    SSR: prefetchQuery → dehydrate


<HydrationBoundary>               client: useQuery reads the hydrated cache

The QueryClient factory is app/lib/query-client.ts, and cache presets live in app/queries/cache-info.tsx. Image rendering and Portable Text are a separate concern — they get their own page at Images & Portable Text.


The Sanity client

There is exactly one client for the whole app, default-exported from app/lib/sanity-client.ts. Never call createClient anywhere else — import this module. A single client means a single place to change config, and a single thing to mock in tests.

app/lib/sanity-client.ts
import { createClient } from "@sanity/client";

const sanityClient = createClient({
  projectId: import.meta.env.VITE_SANITY_PROJECT_ID,
  dataset: import.meta.env.VITE_SANITY_DATASET,
  apiVersion: import.meta.env.VITE_SANITY_API_VERSION,
  useCdn: false,
  token: import.meta.env.VITE_SANITY_WRITE_TOKEN,
});

export default sanityClient;

Two settings carry weight. useCdn: false means reads always hit the live Sanity API rather than the cached CDN — you never serve stale content, at the cost of slower, uncached reads. The token is a write token: the same client is reused for mutations (.create(), .patch()) in app/lib/sanity-mutations.ts, which is why it carries credentials a read-only client would not need.

The write token ships to the browser

Every VITE_* env var is inlined into the client bundle at build time, so VITE_SANITY_WRITE_TOKEN is visible to anyone who opens devtools. Reads do not need it. If you are hardening this app, stand up a separate read-only client (no token) for the query layer and keep the write token on the server. See Setup for the env variables.

There is no server-side cache

useCdn: false plus no server persistence means every cold SSR request re-fetches from Sanity. The only caching is TanStack Query's staleTime / gcTime, which lives in the browser. Each fresh server request starts cold.


Anatomy of a query module

Every CMS domain gets its own folder under app/queries/<domain>/, and every folder follows the same four-file shape. Learn it once and you can read — or scaffold — any domain on the site. Using landing/ as the canonical template:

FileResponsibility
<domain>.query.tsThe GROQ string, wrapped in defineQuery(...), named SCREAMING_SNAKE_CASE ending in _QUERY.
<domain>.options.tsThe queryOptions — the queryKey, the queryFn, and a cache preset.
use-<domain>.tsxA use* hook wrapping useQuery, casting the result to the DTO.
interfaces/<domain>.dto.tsThe result type(s) for the query.

The split is deliberate: the query describes what to fetch, the options describe how to cache it, and the hook is what components actually call. Let's walk each one.

The query file

The query is just a GROQ string handed to defineQuery, imported from the groq package. defineQuery is a typed identity tag — it returns the exact string you pass in, so the value is the query. It does nothing at runtime; it exists for editor highlighting and Sanity's typegen toolchain.

app/queries/landing/landing.query.ts
import { defineQuery } from "groq";

export const LANDING_PAGE_QUERY = defineQuery(`*[
  _type == "landingPage" && _id == "landingPage"
][0]{
  _id,
  hero->{ /* ...projection... */ },
  stats->{ /* ... */ }
}`);

defineQuery does not generate types — your DTO is on you

Typegen is not wired up in this project (there is no generated sanity.types.ts), so a GROQ result comes back typed any. The DTO cast in the hook is therefore unchecked: if a projection and its DTO drift apart, TypeScript will not catch it. Keep the two in sync by hand. A legacy /* groq */ comment style survives on one unused query — ignore it and always use defineQuery for new queries.

The options file

The options file pairs the query with a cache key and a cache preset. The house style is a class with static methods (LandingOptions, BlogOptions, SiteSettingsOptions, and friends); a plain factory function is the rare alternative. Both return a queryOptions(...) object, so they are interchangeable at the call site.

app/queries/landing/landing.options.ts
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: () => sanityClient.fetch(LANDING_PAGE_QUERY),
      ...cache_24_hours,
    });
  }
}

Two conventions live in that object. Query keys are hierarchical arrays grouped by kind["page", ...] for a full-page singleton, ["component", ...] for a reusable section, ["site-settings", ...] for globals, and ["<entity>", id] for a parameterized fetch. And the cache settings come from a named preset, never raw numbers: spread one of the cache_* exports from app/queries/cache-info.tsx (each is just { staleTime, gcTime }). cache_24_hours is the default for CMS content — reach for it unless you have a reason not to.

The hook

The hook is a thin wrapper components call: it runs useQuery with the shared options, casts the any result to the DTO, and re-exposes refetch plus ...rest so callers still get isLoading, error, and so on when they want them.

app/queries/landing/use-landing.tsx
const useLandingPageData = () => {
  const { data, refetch, ...rest } = useQuery(LandingOptions.landingData());
  return {
    landingPageData: data as LandingPageData,
    refetchLandingPageData: refetch,
    ...rest,
  };
};

A non-optional cast can hand you null

Singleton queries end in [0], which returns null when the document does not exist. A cast like as LandingPageData (non-optional) will then type that null as a populated object, and the first .title access throws. Where a document might be absent, prefer as LandingPageData | undefined and guard in the component — see Single Documents & Singletons.


GROQ projections: two patterns the read path owns

GROQ is Sanity's query language: a filter that selects documents, followed by a projection that shapes the fields you get back. Two projection moves are specific to how this read path stitches pages together — the rest is owned elsewhere. Filtering, the load-bearing [0], and fetching by slug live in Single Documents & Singletons; ordering and slicing lists live in Pagination; the image-asset projection lives in Images & Portable Text.

Dereferencing references

Page singletons mostly hold reference fields pointing at separate section documents. Follow a reference and pull fields from the target with the arrow operator ->:

GROQ — dereferencing
*[_type == "landingPage" && _id == "landingPage"][0]{
  hero->{ heading, description },
  stats->{ heading, stats[]{ value, label, icon } }
}

hero->{...} resolves the reference inline. For an array of references, map over it and dereference each one: "categories": categories[]->{ _id, title }. This is how a page pulls itself and all its sections in a single round trip — one GROQ query, the whole rendered page.

Bound parameters

Pass parameters as the second argument to fetch, and reference them with $name inside the query. The blog-post slug is the canonical case — the param goes in the query and in the queryKey so each post caches independently:

app/queries/blog-post/blog-post.query.ts
*[_type == "post" && slug.current == $slug][0]{
  _id, title, body[]{ /* ... */ }
}
app/queries/blog-post/blog-post.options.ts
static blogPost(slug: string) {
  return queryOptions({
    queryKey: ["blog-post", slug],
    queryFn: () => sanityClient.fetch(BLOG_POST_QUERY, { slug }),
    ...cache_24_hours,
  });
}

One thing GROQ does not do here: defaults. coalesce(...) appears nowhere in this codebase — every fallback is applied in JS/TSX instead (value.alt || "Blog post image", siteSettings?.snowflakeCount ?? 400). If you expect a query to supply a default, it will not.


Prefetch on the server, hydrate on the client

This is the flow that ties everything together, and nearly every route uses it. The route loader runs the query on the server and dehydrates the cache; the component re-hydrates that cache and reads it through the hook — with no loading state, because the data is already there.

The loader

app/routes/landing.tsx is the reference. It prefetches several queries in parallel with Promise.all, then returns the dehydrated cache as loader data:

app/routes/landing.tsx
export async function loader() {
  const queryClient = getQueryClient();
  await Promise.all([
    queryClient.prefetchQuery(LandingOptions.landingData()),
    queryClient.prefetchQuery(CampusListOptions.campusListData()),
    queryClient.prefetchQuery(BlogOptions.blogPosts()),
  ]);
  return { dehydratedState: dehydrate(queryClient) };
}

When a route needs only one query, drop Promise.all and await a single prefetchQuery.

Always use getQueryClient() — never a module-level new QueryClient()

getQueryClient() returns a fresh client per request on the server and a singleton in the browser. A shared server instance would leak one user's prefetched data into another user's dehydratedState. That per-request instance is exactly what makes dehydrate SSR-safe.

The component

The default export wraps the real content in <HydrationBoundary>; the inner component just calls the hook and renders. There is no loading branch because the cache is already warm:

app/routes/landing.tsx
const LandingPage = ({ loaderData }: Route.ComponentProps) => (
  <HydrationBoundary state={loaderData.dehydratedState}>
    <LandingPageContent />
  </HydrationBoundary>
);

const LandingPageContent = () => {
  const { landingPageData } = useLandingPageData(); // already in cache, no spinner
  // ...render with landingPageData
};

The magic that prevents a redundant client fetch is one default in app/lib/query-client.ts: refetchOnMount: false. Because the hydrated data already exists in the cache, the hook reads it instead of firing a fresh request on mount.

HydrationBoundary is per-route, on purpose

The app's QueryProvider wraps everything in QueryClientProvider but deliberately omits a global HydrationBoundary — each route hydrates its own dehydratedState. A global boundary would double-hydrate. Data reaches components through Route.ComponentProps['loaderData']; useLoaderData is never used here. See Routing & Pages.

meta() reads the same cache and never queries again — it reaches into the dehydrated state, finds the prefetched query by matching its queryKey, and reads .state.data, so the SEO fields ride along for free from the work the loader already did.


One escape hatch: the direct fetch

There is exactly one place that bypasses TanStack Query. app/root.tsx fetches site settings directly with a typed generic, used only to set the favicon — no caching, no hydration, no hook:

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

The fetch<T>() generic hands you a typed plain object straight back. Reach for this only when you genuinely do not need caching or client-side reads; everything else goes through a query module.


Where to go next

On this page