Paginating CMS Collections with useSWRInfinite
As part of SWR Stale-While-Revalidate Patterns, this guide builds paginated CMS listings with useSWRInfinite: a getKey function that encodes each platform’s pagination, the options that control how many pages revalidate, and the handling that keeps a listing consistent when editors publish while a reader is scrolling.
useSWRInfinite differs from single-key SWR in one important way: it stores each page under its own key and keeps a separate record of how many pages are loaded. That makes individual pages cheap to cache and revalidate, and it means the page boundaries are defined entirely by your getKey function. Get getKey right and the rest of the hook behaves predictably.
The Problem
An online magazine runs on Directus and lists articles by topic with a “Show more” button. The team’s first getKey returned /api/cms/articles?topic=${topic}&page=${index + 1} without checking the previous page, so after the last page the button kept requesting empty pages forever. After fixing that, a second problem appeared: readers who opened the listing in the morning and pressed “Show more” in the afternoon got pages that overlapped with what they already saw, because new articles had been published in between and every offset had shifted. And every return to the tab triggered revalidation of all loaded pages, twelve requests for a reader twelve pages deep.
All three are configuration problems: a getKey that knows when to stop, a stable sort with deduplication, and the revalidation options that decide which pages refetch.
How useSWRInfinite Revalidates
The hook exposes size and setSize. Increasing size fetches the next page; the data array holds one entry per page. When the listing revalidates, on focus, reconnect or an explicit mutate(), useSWRInfinite refetches pages sequentially from the first, passing each fresh page to getKey for the next. Two options change that behaviour:
revalidateFirstPage(defaulttrue) refetches page one wheneversizechanges. That catches new entries at the top of a newest-first list, at the cost of an extra request per “Show more”.revalidateAll(defaultfalse) refetches every loaded page on revalidation. It keeps deep listings exactly current but multiplies requests.parallelfetches pages concurrently instead of sequentially. It is only valid when page keys do not depend on the previous page’s data, as with page numbers and offsets but not with cursors.
For CMS listings, the useful combination is usually revalidateFirstPage: true, revalidateAll: false, stable ordering and client-side deduplication. New content appears at the top, deeper pages stay cached, and any overlap caused by shifted offsets is removed before rendering.
Implementation
The hook below targets Directus. It stops when a page comes back shorter than the page size, sorts by publish date with the id as a tie-breaker, and dedupes across pages before rendering.
// hooks/use-article-pages.ts
import useSWRInfinite from "swr/infinite";
import { useMemo } from "react";
const PAGE_SIZE = 12;
interface Article {
id: string;
slug: string;
title: string;
date_published: string;
}
interface DirectusPage {
data: Article[];
}
function makeGetKey(topic: string, locale: string) {
return (index: number, previous: DirectusPage | null): string | null => {
if (previous && previous.data.length < PAGE_SIZE) return null; // reached the end
const qs = new URLSearchParams({
"filter[topic][slug][_eq]": topic,
"filter[status][_eq]": "published",
sort: "-date_published,id",
limit: String(PAGE_SIZE),
offset: String(index * PAGE_SIZE),
fields: "id,slug,title,date_published",
locale,
});
return `/api/cms/articles?${qs.toString()}`;
};
}
export function useArticlePages(topic: string, locale: string) {
const swr = useSWRInfinite<DirectusPage>(makeGetKey(topic, locale), {
revalidateFirstPage: true,
revalidateAll: false,
parallel: true, // offsets do not depend on previous data
revalidateOnFocus: false,
dedupingInterval: 5000,
});
const articles = useMemo(() => {
const seen = new Set<string>();
return (swr.data ?? []).flatMap((page) => page.data).filter((a) => (seen.has(a.id) ? false : (seen.add(a.id), true)));
}, [swr.data]);
const last = swr.data?.[swr.data.length - 1];
const reachedEnd = !!last && last.data.length < PAGE_SIZE;
const loadingMore = swr.size > 0 && swr.data !== undefined && typeof swr.data[swr.size - 1] === "undefined";
return { articles, reachedEnd, loadingMore, loadMore: () => swr.setSize(swr.size + 1), mutate: swr.mutate };
}
The component renders articles, shows the button while !reachedEnd, and disables it while loadingMore. On a publish event for the topic, call the returned mutate(); with revalidateAll: false it refetches only the first page and whatever the hook needs to rebuild page boundaries.
Adapting getKey to a cursor API
If a GraphQL gateway or a Relay-style CMS endpoint exposes cursors, getKey reads the previous page’s end cursor instead of computing an offset. Cursors do not shift when entries are published, which removes the overlap problem entirely, but pages must load sequentially:
interface CursorPage {
items: Article[];
pageInfo: { endCursor: string | null; hasNextPage: boolean };
}
export const getCursorKey = (topic: string) => (index: number, previous: CursorPage | null): string | null => {
if (previous && !previous.pageInfo.hasNextPage) return null;
const after = index === 0 ? "" : `&after=${encodeURIComponent(previous?.pageInfo.endCursor ?? "")}`;
return `/api/cms/articles-connection?topic=${topic}&first=12${after}`;
};
Leave parallel off with this getKey: page two’s key cannot be computed until page one has arrived.
Configuration Reference
| Option | Value | Effect |
|---|---|---|
initialSize |
1 | Pages loaded on mount; raise it to restore a deep scroll position. |
revalidateFirstPage |
true |
Picks up new entries at the top of newest-first listings. |
revalidateAll |
false |
Keeps deep pages cached instead of refetching them all. |
parallel |
true for offsets |
Loads several pages at once when initialSize is greater than one. |
persistSize |
false |
Resets to initialSize when the key changes, for example on a new topic. |
| Page size | 12 | Balances response time and scroll distance. |
Gotchas & Edge Cases
- Forgetting the end condition. A
getKeythat never returnsnullmakessetSizerequest empty pages indefinitely. Always stop on a short page, a page count or a missing cursor. - Filters outside getKey. If the topic comes from state but
getKeycloses over a stale value, pages from two topics mix in one listing. RecreategetKeywhen filters change, asmakeGetKey(topic, locale)does on each render, so the first page key changes too. - Unstable sort order. Sorting by publish date alone lets entries with equal timestamps swap places between requests, which offset pagination turns into skipped or repeated items. Add a unique tie-breaker such as the id.
- Deep links to page N. Restoring a reader to page five on back-navigation needs
initialSize: 5andparallel: true, or the pages load one after another. Store the size insessionStoragekeyed by listing. - Server rendering.
useSWRInfinitesupportsfallbackDataas an array of pages. Server-render only the first page and pass it asfallbackData: [firstPage].
Verifying the Result
Load four pages, then publish a new article in the topic and press “Show more”: the new article should appear at the top after the first-page revalidation, page five should load, and no article should appear twice. In the Network panel, the press should cost two requests (page one and page five), not five. Return to the tab after an hour: with revalidateOnFocus: false, nothing should be requested until the reader interacts or a publish event arrives.
Accessibility deserves a word here, because infinite listings are easy to make unusable with a keyboard or a screen reader. Keep an explicit “Show more” button even when scrolling also loads pages, move focus to the first newly loaded item after a load, and announce the number of added items through a polite live region. The hook’s loadingMore flag drives both the button’s disabled state and the announcement.
Rollout Checklist
- Write
getKeywith an explicit end condition for your CMS’s pagination format. - Sort by publish date plus a unique id, and dedupe by id before rendering.
- Enable
parallelonly for offset or page-number keys, never for cursors. - Keep
revalidateAlloff for long listings; rely on first-page revalidation and publish events. - Server-render the first page and pass it as
fallbackData.
Frequently Asked Questions
When should I use useSWRInfinite instead of a single useSWR with page state?
Use useSWRInfinite whenever pages accumulate on screen, as in “show more” and infinite scroll. For classic numbered pagination, where only one page is visible at a time, a plain useSWR with the page number in the key is simpler and caches each page just as well.
How do I invalidate one topic’s listing after a publish?
Call the mutate returned by the hook in components that have it mounted, or use the global mutate with a matcher for keys starting with /api/cms/articles? and containing the topic parameter. The infinite hook also stores a special key for its page count; the bound mutate handles that for you.
Does parallel loading overload the CMS?
It sends initialSize requests at once when restoring a deep scroll position, which is the case it exists for. During normal use, pages load one at a time as the reader asks for them, so parallel mode changes nothing.
Can I combine this with a total count from the CMS?
Yes. Request the count with the first page, using meta=filter_count in Directus or total in Contentful, and read it from data[0]. Recompute it after first-page revalidation, because publishes change it.