Infinite CMS Listings with useInfiniteQuery
This guide, part of React Query for CMS Data, builds a “load more” or infinite-scroll listing on top of a headless CMS with useInfiniteQuery, including the page parameter each CMS expects, a memory cap for long sessions, and the refetch behaviour that keeps pages consistent after editors publish.
Listings are where CMS integrations accumulate the most accidental complexity. Each platform paginates differently: Contentful uses skip and limit with a total, Strapi uses pagination[page] or pagination[start] with a meta.pagination block, and Directus uses offset and limit with an optional meta=filter_count. The listing component should not know any of that. useInfiniteQuery gives a single abstraction, as long as the page parameter logic lives in one adapter per CMS.
The Problem
A recipe site on Strapi shows a “More recipes” button under a category listing. The first implementation keeps pages in component state and appends each response to an array. Three bugs follow within weeks. Navigating to a recipe and back resets the list to page one, because the state died with the component. After an editor publishes a new recipe, pressing “More” shows a recipe that was already on screen, because the new entry shifted every offset by one. And on a phone left open on the listing for an hour, scrolling through two hundred pages keeps every image and every page in memory until the tab crashes.
useInfiniteQuery fixes the first bug for free, because pages live in the query cache and survive navigation. The other two need deliberate configuration: stable ordering with deduplication for the offset shift, and maxPages for memory.
How Infinite Queries Work
An infinite query stores data.pages, an array of page responses, and data.pageParams, the param each page was fetched with. fetchNextPage() calls getNextPageParam(lastPage, allPages) to compute the next param, fetches, and appends. Returning undefined from getNextPageParam sets hasNextPage to false.
The critical behaviour is refetching. When the query becomes stale and refetches (on mount, focus or invalidation), React Query refetches every page sequentially, starting from the first param and recomputing each following param from the fresh response. This keeps pages consistent with each other. If a new recipe was published at the top, the refetched pages shift together, and no page contains a duplicate of its neighbour. The cost is one request per loaded page, which is why maxPages matters.
Implementation
The adapter below targets Strapi v5’s REST API. The hook takes the adapter’s types and knows nothing about Strapi, so switching CMS means writing a new twenty-line adapter.
// lib/strapi-list.ts
interface StrapiPage<T> {
data: T[];
meta: { pagination: { start: number; limit: number; total: number } };
}
export interface Recipe {
documentId: string;
slug: string;
title: string;
publishedAt: string;
}
export interface ListPage<T> {
items: T[];
start: number;
limit: number;
total: number;
}
export async function fetchRecipePage(category: string, locale: string, start: number, limit = 12): Promise<ListPage<Recipe>> {
const qs = new URLSearchParams({
"filters[category][slug][$eq]": category,
locale,
// Stable total order: newest first, then documentId as a tie-breaker.
"sort[0]": "publishedAt:desc",
"sort[1]": "documentId:asc",
"pagination[start]": String(start),
"pagination[limit]": String(limit),
"fields[0]": "slug",
"fields[1]": "title",
"fields[2]": "publishedAt",
});
const res = await fetch(`/api/cms/recipes?${qs.toString()}`);
if (!res.ok) throw new Error(`Strapi ${res.status}`);
const body = (await res.json()) as StrapiPage<Recipe>;
return { items: body.data, ...body.meta.pagination };
}
// hooks/use-recipe-list.ts
import { useInfiniteQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { cmsKeys, type Locale } from "@/lib/cms-keys";
import { fetchRecipePage, type Recipe } from "@/lib/strapi-list";
export function useRecipeList(category: string, locale: Locale) {
const query = useInfiniteQuery({
queryKey: cmsKeys.list("article", { locale, filters: { category } }),
queryFn: ({ pageParam }) => fetchRecipePage(category, locale, pageParam),
initialPageParam: 0,
getNextPageParam: (last) => (last.start + last.limit < last.total ? last.start + last.limit : undefined),
// Keep at most 8 pages (96 recipes) in memory; older pages are dropped
// and refetched via getPreviousPageParam if the reader scrolls back.
maxPages: 8,
getPreviousPageParam: (first) => (first.start > 0 ? Math.max(0, first.start - first.limit) : undefined),
staleTime: 5 * 60 * 1000,
});
// Flatten and dedupe: an offset shift during fetchNextPage can repeat one item.
const items = useMemo(() => {
const seen = new Set<string>();
const out: Recipe[] = [];
for (const page of query.data?.pages ?? []) {
for (const r of page.items) {
if (!seen.has(r.documentId)) {
seen.add(r.documentId);
out.push(r);
}
}
}
return out;
}, [query.data]);
return { ...query, items };
}
The secondary sort on documentId is not decoration. Without a unique tie-breaker, entries with the same publishedAt, which is common after a bulk import, can swap order between requests, and offset pagination then skips or repeats them across pages.
For true infinite scroll, trigger fetchNextPage from an IntersectionObserver on a sentinel element below the list, and guard it with hasNextPage && !isFetchingNextPage so a fast scroll cannot queue duplicate requests.
Server-Rendering the First Page
A listing that renders empty until JavaScript loads is bad for search engines and for Largest Contentful Paint. Render the first page on the server and hydrate it into the infinite query, so the client picks up exactly where the server left off. With the Next.js App Router and a request-scoped QueryClient:
// app/[locale]/recipes/[category]/page.tsx
import { HydrationBoundary, QueryClient, dehydrate } from "@tanstack/react-query";
import { cmsKeys, type Locale } from "@/lib/cms-keys";
import { fetchRecipePage } from "@/lib/strapi-list";
import { RecipeList } from "./recipe-list";
export default async function Page({ params }: { params: Promise<{ locale: Locale; category: string }> }) {
const { locale, category } = await params;
const qc = new QueryClient();
await qc.prefetchInfiniteQuery({
queryKey: cmsKeys.list("article", { locale, filters: { category } }),
queryFn: ({ pageParam }) => fetchRecipePage(category, locale, pageParam),
initialPageParam: 0,
});
return (
<HydrationBoundary state={dehydrate(qc)}>
<RecipeList category={category} locale={locale} />
</HydrationBoundary>
);
}
On the server, fetchRecipePage must call Strapi directly or through an absolute URL, since relative proxy paths only resolve in the browser. Pass the base URL through the adapter instead of hard-coding /api/cms. Keep pages to one on the server: every additional server-rendered page adds HTML weight and delays first paint for content the reader may never scroll to.
Configuration Reference
| Option | Value | Why |
|---|---|---|
initialPageParam |
0 |
First offset; required in TanStack Query v5. |
getNextPageParam |
offset + limit while below total | Return undefined to end the list. |
maxPages |
5 to 10 | Caps memory and the number of requests a full refetch sends. |
getPreviousPageParam |
required with maxPages |
Lets dropped pages be fetched again when scrolling back. |
| Page size | 12 to 24 | Small enough for fast responses, large enough to fill a screen. |
| Sort | a date plus a unique id | A total order prevents skips and repeats between pages. |
Gotchas & Edge Cases
- Refetch cost grows with pages. Invalidating a listing with fifteen loaded pages sends fifteen sequential requests. Use
maxPages, or on a publish invalidate withrefetchType: "none"and let the listing refetch when it is next viewed. - Filters in the key, not in closures. A category filter read from component state inside
queryFnbut missing from the key makes every category share one cache entry. The factory’sfiltersparameter exists for this. - Cursor APIs. If the CMS or a GraphQL gateway offers cursors, prefer them:
getNextPageParamreturnspageInfo.endCursor, and new entries at the top no longer shift pages at all. - Server-rendered first page. Prefetch with
prefetchInfiniteQueryusing the same key andinitialPageParam, then hydrate. Only the first page should be server-rendered; later pages are a client concern. - Scroll restoration. Pages survive navigation in the cache, but the browser’s scroll position may be restored before the list renders. Render from cached data synchronously, which
useInfiniteQuerydoes when data exists, and avoid layout that depends on images without dimensions.
Verifying the Result
Load three pages, open a recipe, press back: the list should render all three pages instantly from cache, at the same scroll position. Publish a new recipe in Strapi, trigger invalidation and load one more page. No recipe should appear twice, and the new recipe should appear at the top after the refetch. In DevTools, the listing query should show at most maxPages entries in data.pages however far you scroll.
Rollout Checklist
- Write one page adapter per CMS and keep every platform-specific parameter inside it.
- Sort by a date plus a unique id, so offsets always address a total order.
- Build the key with the shared factory, including locale and every filter.
- Set
maxPagestogether withgetPreviousPageParam, and test scrolling back up. - Server-render only the first page; hydrate it with the same key and
initialPageParam. - Guard
fetchNextPagewithhasNextPage && !isFetchingNextPagein scroll handlers. - On publish, invalidate the lists prefix; consider
refetchType: "none"for long listings.
Frequently Asked Questions
Should I use page numbers or offsets for Strapi?
Offsets (pagination[start]) compose more cleanly with getNextPageParam, because the next offset follows directly from the response. Page numbers work too, but switching page size later then requires recomputing every param.
How do I reset the listing when a filter changes?
Changing a filter changes the key, which starts a new infinite query from initialPageParam. The old listing stays cached for its gcTime, so switching back to the previous filter shows its pages instantly.
Can I combine infinite loading with a total count?
Yes. The first page’s total is available as data.pages[0].total. Show “Showing 36 of 214 recipes” from it, but recompute it from the latest page after a refetch, because publishes can change the total.