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.

How useSWRInfinite derives page keysgetKey is called with a page index and the previous page's data; it returns the key for that page or null to stop, and each key is fetched and cached independently.size = 3pages requestedgetKey(i, prev)?page=1?page=2?page=3nullprev page emptyi=0i=1i=2end
Returning null from getKey is how the hook learns that the collection has ended.

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 (default true) refetches page one whenever size changes. That catches new entries at the top of a newest-first list, at the cost of an extra request per “Show more”.
  • revalidateAll (default false) refetches every loaded page on revalidation. It keeps deep listings exactly current but multiplies requests.
  • parallel fetches 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.

getKey for four CMS APIsHow getKey builds page keys for Directus, Strapi, Contentful and a cursor-based GraphQL gateway, and how it detects the end of the collection.APIPage keyEnd whenparallel ok?Directus?offset=i*n&limit=nprev.data.length < nyesStrapi?pagination[page]=i+1page > meta.pageCountyesContentful?skip=i*n&limit=nskip >= prev.totalyesGraphQL gateway?after=prev.endCursor!prev.hasNextPageno, needs prev
Offset and page-number APIs can load pages in parallel; cursor APIs must load sequentially.

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.

TypeScript
// 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:

TypeScript
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 getKey that never returns null makes setSize request 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 getKey closes over a stale value, pages from two topics mix in one listing. Recreate getKey when filters change, as makeGetKey(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: 5 and parallel: true, or the pages load one after another. Store the size in sessionStorage keyed by listing.
  • Server rendering. useSWRInfinite supports fallbackData as an array of pages. Server-render only the first page and pass it as fallbackData: [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.

Requests when pressing Show more on page 12Number of requests triggered by one Show more press twelve pages deep under three option combinations.revalidateAll: true13 requestsrevalidateFirstPage: true2 requestsboth false1 requestsThe single-request option misses new entries at the top until the next explicit revalidation.
revalidateAll turns every interaction deep in a listing into a full refetch.

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 getKey with 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 parallel only for offset or page-number keys, never for cursors.
  • Keep revalidateAll off 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.