Pagination
How list pagination really works here — the client-side load-more pattern, and an honest note on what isn't built.
Here's the thing about "pagination" on the Naalya site: there almost isn't any. No page numbers, no "showing 9 of 240", no ?page=2 in the URL. When you go looking for the usual server-side machinery — count(), $start/$end window variables, integer URL state — you won't find it, and that absence is on purpose.
What you'll find instead is one honest pattern. The single real list on the site — the Updates page — fetches every post in one request, then reveals them a screen at a time with a "Load more" button. Filtering and "pagination" are just array operations on data that's already in memory. It's deliberately boring, and for a school site's content volume that's exactly right.
This page assumes you've met the read pipeline already — the per-feature query module, TanStack queryOptions, and SSR prefetch into a HydrationBoundary. If those words are new, read Querying with GROQ first; everything below sits on top of it.
Why so little machinery?
The team made a trade: post volume is low, so the whole list is a cheap payload. Shipping it all into the SSR response means crawlers see real content with no client round-trip, and cache_24_hours means each client fetches it at most once a day. In-memory pagination is effectively free after that — so there was no reason to build page-number routing or count() queries.
The mental model: fetch all, reveal some
The whole pattern is four moves, and you'll reuse them for any list:
- Fetch everything once with an unsliced GROQ query.
- Prefetch it on the server so the full payload lands in the dehydrated state.
- Reveal a window with a
visibleCountstate andArray.slice(0, visibleCount). - Filter (optionally) with React Router search params, resetting the window on change.
No step touches the server again after the first load. "Load more" doesn't fetch — it just raises visibleCount and re-slices the array you already have.
Step 1: The query fetches the whole list
The query in app/queries/blog/blog.query.ts pulls every published post — no range slice — plus the category list, in a single round trip. The shape is trimmed here; the real projection carries image metadata and the full post card fields:
{
"posts": *[
_type == "post" && defined(publishedAt)
] | order(publishedAt desc){
_id, title, slug,
"author": author->name,
"date": publishedAt,
"excerpt": body[0].children[0].text,
"categories": categories[]->{ _id, title },
mainImage{ /* asset->{ url, metadata }, hotspot, crop */ }
},
"categories": *[_type == "category"]{ _id, title }
}The | order(publishedAt desc) sorts newest-first on the server, so the array arrives in display order. There's no [0...n] on it — that's the deliberate part. Slicing happens in the component, not the query.
Two lists, two visibility rules
This query filters defined(publishedAt) but not showPreview. The homepage's recent-posts strip filters showPreview == true as well — so a post can show on /updates yet be hidden from the homepage. When you build a new list, decide which rule you want; it's easy to copy the wrong filter by reflex.
The options factory and hook are the standard mechanical shapes — no parameters, 24-hour cache, a ["page", ...] query key:
static allPosts() {
return queryOptions({
queryKey: ["page", "updates", "all-posts"],
queryFn: () => sanityClient.fetch(ALL_POSTS_QUERY),
...cache_24_hours,
});
}Step 2: The loader prefetches everything
The route loader in app/routes/updates.tsx prefetches the full list and dehydrates it. The page then wraps its content in a <HydrationBoundary>, so the hook reads from an already-warm cache with no client spinner:
export async function loader() {
const queryClient = getQueryClient();
await queryClient.prefetchQuery(BlogOptions.allPosts());
return { dehydratedState: dehydrate(queryClient) };
}
const UpdatesPage = ({ loaderData }: Route.ComponentProps) => (
<HydrationBoundary state={loaderData.dehydratedState}>
<UpdatesPageContent />
</HydrationBoundary>
);This is identical to any other prefetching loader on the site — there's no special "list loader". The whole point is that pagination needs nothing from the server beyond this one fetch.
Step 3: Load-more state lives in the component
This is the part to copy. A PAGE_SIZE constant, a visibleCount state seeded to it, a slice(0, visibleCount) for the visible window, a hasMore guard, and a handler that bumps the count. Notice the ordering — filter first, then slice the filtered result:
const POSTS_PER_PAGE = 9;
const UpdatesPageContent = () => {
const { allPostsData } = useAllPosts();
const [searchParams, setSearchParams] = useSearchParams();
const categoryParam = searchParams.get("category") || "all";
const [visibleCount, setVisibleCount] = useState(POSTS_PER_PAGE);
const posts = allPostsData?.posts || [];
// 1. Filter the full set (memoized) ...
const filteredPosts = useMemo(() => {
if (categoryParam === "all") return posts;
return posts.filter((post) =>
post.categories?.some(
(cat) => cat.title.toLowerCase() === categoryParam.toLowerCase()
)
);
}, [posts, categoryParam]);
// 2. ... then reveal a window of it
const visiblePosts = filteredPosts.slice(0, visibleCount);
const hasMore = visibleCount < filteredPosts.length;
const handleLoadMore = () =>
setVisibleCount((prev) => prev + POSTS_PER_PAGE);
};The render maps visiblePosts and gates the button on hasMore — when there's nothing left to reveal, the button simply isn't there:
{visiblePosts.map((post, i) => (
<PostCard key={post._id} index={i} post={post} />
))}
{hasMore && (
<Button onClick={handleLoadMore} variant="outline">Load more</Button>
)}Filter before you slice — or the button lies
filteredPosts.slice(0, visibleCount) paginates the filtered set, and hasMore compares against filteredPosts.length. Slice the raw array first and the counts go wrong: "Load more" would reveal posts outside the active category, or stay visible when the category is already exhausted. The order is load-bearing.
Step 4: Filtering, and resetting the window
The category filter rides React Router's useSearchParams, not nuqs (more on that below). The one rule that matters: when the filter changes, reset visibleCount so the new result set starts from the top instead of inheriting a window meant for the old one.
const handleCategoryChange = (value: string) => {
setSearchParams(value === "all" ? {} : { category: value });
setVisibleCount(POSTS_PER_PAGE); // restart at the first page
};That's the entire interactive surface — a URL-backed filter and an in-memory window. Reload the page and you're back to the first nine; the visibleCount cursor lives in component state and is not reflected in the URL.
What is deliberately NOT here
Half of understanding this page is knowing what to stop looking for. None of the following exist anywhere in app/, and grepping for them will only mislead you:
| You might expect… | Reality on this site |
|---|---|
count(*[...]) totals | None. There's no "showing 9 of 240" anywhere. |
$start / $end range variables | None. The only slice in the codebase is a fixed [0...5] cap on the homepage strip. |
Numbered pages (?page=2) | None. Just a "Load more" button. |
nuqs parseAsInteger page state | None. nuqs is wired up, but only for string params like ?section=. |
The one slice in the codebase is a cap, not pagination
The homepage recent-posts strip ends its query in [0...5] — give me the five newest. Three dots make it end-exclusive: indices 0 through 4, five items. It's a hard limit for a widget, not a page-one-of-many. Treat it as the canonical example of GROQ slice syntax, and don't mistake it for a pagination feature — it's the only [n...m] slice in the whole app/queries/ tree.
Don't grep for nuqs expecting pagination
nuqs is installed and its adapter is wired, but its only app-code uses are non-list string params — the active scroll section and the AI chat instructions. There is no parseAsInteger and no useQueryStates anywhere. If you ever want a shareable, back-button-friendly page cursor, adding parseAsInteger via nuqs is the move — but understand you're introducing a new pattern, not following one.
When fetch-all stops being the right call
Be honest with yourself about scale. Because the entire post set ships in every SSR payload and lives in memory, this approach degrades as the list grows. It's the right tool for tens or low-hundreds of documents — the volumes this site actually has. It is the wrong tool for thousands.
If a list ever gets that big, that's your cue to introduce real server-side range pagination — and there's no existing helper, so you'd be setting the convention. GROQ supports bound variables in the slice, paired with a count() query to learn how many pages exist:
*[_type == "post" && defined(publishedAt)]
| order(publishedAt desc)[$start...$end]{ _id, title, slug }
count(*[_type == "post" && defined(publishedAt)])You'd compute start/end from a page number on the client and bind them at fetch time. If you build this, update this page — right now, fetch-all is the documented and intended pattern.
Where to go next
Querying with GROQ
The read pipeline these lists ride on: defineQuery, options factories, SSR prefetch, hydration.
Single Documents & Singletons
The other side: fetching one document by slug or fixed _id, not a list.
The Content Model
The post schema — slug, publishedAt, showPreview, categories — that backs these queries.
Routing & Pages
How React Router v7 loaders prefetch data and wire up routes like /updates.