Single Documents & Singletons
How the site fetches one Sanity document — posts by slug, and the fixed-_id singleton pattern behind every page and section.
Most of the time you aren't fetching a list from Sanity — you want one specific thing: this blog post, the homepage, the site-wide settings. On the Naalya site that "fetch one document" job comes in exactly two flavors, and once you can spot them, almost every page falls into place.
- By slug — content with many instances that you address by a URL, like a blog post at
/blog/my-post. - As a singleton — content there should only ever be one of: the landing page, a hero section, the global site settings.
Both ride the same plumbing you met in Querying with GROQ — a query module, a loader that prefetches, hydration on the client. The only thing that changes between them is the shape of the GROQ filter.
The one rule that ties them together
A GROQ filter (*[...]) always returns an array. When you want a single document, end the query with [0] to collapse that array to its first element — or to null if nothing matched. Forget the [0] and your component receives a one-item array where it expected an object.
Fetching a post by slug
A blog post is the site's only "many instances, read one at a time" content. The route is /blog/:postSlug, so the slug arrives as a route param, and the query simply matches on it:
export const BLOG_POST_QUERY = defineQuery(`*[
_type == "post" && slug.current == $slug
][0]{
_id, title, slug, seoTitle, seoDescription,
"author": author->{ name, image{ /* ... */ } },
mainImage{ asset->{ url, metadata } },
body[]{ /* portable text */ },
}`);Read it left to right: find documents of type post whose slug matches the one I pass in, take the first, and project the fields the page needs. The -> on author follows a reference and inlines the author's fields. The full projection — image metadata, categories, the portable-text body — lives in the real file; you rarely need to memorize it.
Binding the $slug parameter happens in the options factory, and the slug becomes part of the cache key so every post caches independently:
static blogPost(slug: string) {
return queryOptions({
queryKey: ["blog-post", slug],
queryFn: () => sanityClient.fetch(BLOG_POST_QUERY, { slug }),
...cache_24_hours,
});
}Turning "not found" into a real 404
Here's the one subtlety worth slowing down for. A detail page should return a proper 404 when the slug doesn't exist — not a blank page. The loader does that by prefetching the post and then reading it straight back out of the cache to check:
export async function loader({ params }: Route.LoaderArgs) {
const queryClient = getQueryClient();
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 });
return { slug: params.postSlug, dehydratedState: dehydrate(queryClient) };
}Await the prefetch first — always
getQueryData reads the cache synchronously. It only finds the post because prefetchQuery was awaited on the line above. Drop the await (or read before prefetching) and the cache is empty, so every request throws a 404. The order is non-negotiable: prefetch, then read.
And that's the whole slug pattern. The hook and the SEO meta() tags follow the standard module convention from Querying with GROQ — nothing new to learn here.
Singletons: when there should only be one
A singleton is a document the site expects exactly one of — siteSettings, the landingPage, a single heroSection. The project handles every one of them with a single convention:
Give the document a fixed
_idequal to its_type, and query it by that id.
So siteSettings lives at _id == "siteSettings", the landing page at _id == "landingPage", and so on. You never hunt for a random document ID — the type name is the address:
export const SITE_SETTINGS_QUERY = defineQuery(`*[
_type == "siteSettings" && _id == "siteSettings"
][0]{ _id, siteName, favicon{ asset->{ url } }, campusContacts[]{ campus, phone } }`);Because the id is baked into the query, the options factory takes no parameters — there's nothing to bind. That's the tell-tale sign you're looking at a singleton.
A missing singleton is null, not an error
If nobody has filled in the document yet, the query quietly returns null — Sanity doesn't throw. That's why singleton hooks like useSiteSettings guard with if (!data) return null and skip rendering. When you add a new singleton, remember to open it once in the Studio to actually create the document, or the hook returns null forever.
Pages are singletons that point at other singletons
Most "page" singletons hold almost no content of their own. Instead they carry a handful of reference fields — one per visible section — and each section is itself a singleton. The landing page pulls itself and all of its sections in one query by dereferencing each reference with ->:
*[_type == "landingPage" && _id == "landingPage"][0]{
seoTitle, seoDescription,
hero->{ heading, heroImage{ /* ... */ } },
stats->{ heading, stats[]{ value, label } },
about->{ /* ... */ }, gallery->{ /* ... */ }, cta->{ /* ... */ }
}One round trip, the whole page. This is why "edit the homepage" in the Studio means editing several linked documents — and why these schemas read as a page of references. (Schemas covers how that content model is built.)
Singletons are enforced by the Studio, not the schema
Here's the part that trips people up: nothing in the schema marks a type as a singleton. "Only one of these" is enforced entirely in sanity/sanity.config.ts, through two cooperating moves:
- The Structure tool pins each type to a fixed document —
documentId("siteSettings")— so clicking it always opens the same document. - That type is removed from the generic "create new document" list, so an editor simply can't make a second one.
Trust sanity.config.ts, not the markdown
sanity/SINGLETON_PATTERN.md explains the why but lists only a handful of examples. The authoritative list of singletons is the exclusion array in sanity.config.ts (~75 types). When you need to know whether something is a singleton, read that file — not the doc.
Two variations you'll run into
The fixed-id form covers most cases. Two cousins show up occasionally — recognize them, don't memorize them:
Dynamic id. A few section types (academicOverview, academicProgram) can be addressed by a passed-in id rather than a hard-coded one — _id == $id. When the id is dynamic, the options factory guards the query so it never runs empty:
static byId(id: string) {
return queryOptions({
queryKey: ["section", "academic-overview", id],
queryFn: () => sanityClient.fetch(ACADEMIC_OVERVIEW_BY_ID_QUERY, { id }),
enabled: Boolean(id), // don't fire with an empty id
...cache_24_hours,
});
}Singleton families. A few types have a small, fixed set of instances — campusContactSection exists once per campus (campusContactSection-namugongo, -lugazi, -bweyogerere). The frontend never queries these directly; the parent contactUsPage singleton holds references to them and reaches each one with ->.
Two different 'singletons' — don't conflate them
app/queries/base.query.ts defines a BaseQuery class — that's a JavaScript singleton for the REST/axios API, with nothing to do with Sanity. No Sanity query extends it. The word collides; the concepts don't.
Quick reference
When you sit down to write one of these, here's the cheat sheet. Notice every row ends in [0]:
| You want… | Filter | Param | Watch out for |
|---|---|---|---|
| A post by slug | slug.current == $slug | { slug } | 404 in the loader when missing |
| A standard singleton | _id == "<type>" | none | hook returns null if absent |
| A singleton by dynamic id | _id == $id | { id } | add enabled: Boolean(id) |
| A singleton family member | reach via parent field->{…} | none | created in the Studio with a suffixed id |