React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

swrpaginationuseSWRInfinitecursoroffset

Pagination with SWR

Recipe

Use useSWRInfinite to handle paginated and infinite-scroll data. Define a getKey function that returns the correct URL for each page index, and call setSize to load more pages.

"use client";
 
import useSWRInfinite from "swr/infinite";
 
const fetcher = (url: string) => fetch(url).then((r) => r.json());
 
const getKey = (pageIndex: number, previousPageData: any[] | null) => {
  if (previousPageData && previousPageData.length === 0) return null; // reached the end
  return `/api/posts?page=${pageIndex}&limit=10`;
};
 
function PostFeed() {
  const { data, size, setSize, isLoading, isValidating } = useSWRInfinite(getKey, fetcher);
 
  const posts = data ? data.flat() : [];
  const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === "undefined");
  const isEmpty = data?.[0]?.length === 0;
  const isReachingEnd = isEmpty || (data && data[data.length - 1]?.length < 10);
 
  return (
    <div>
      {posts.map((post) => (
        <article key={post.id}>{post.title}</article>
      ))}
      <button
        disabled={isLoadingMore || isReachingEnd}
        onClick={() => setSize(size + 1)}
      >
        {isLoadingMore ? "Loading..." : isReachingEnd ? "No more posts" : "Load more"}
      </button>
    </div>
  );
}

Working Example

"use client";
 
import useSWRInfinite from "swr/infinite";
 
interface Product {
  id: string;
  name: string;
  price: number;
}
 
interface PageResponse {
  items: Product[];
  nextCursor: string | null;
}
 
const fetcher = (url: string): Promise<PageResponse> =>
  fetch(url).then((r) => r.json());
 
export default function ProductCatalog() {
  const { data, size, setSize, isLoading } = useSWRInfinite<PageResponse>(
    (pageIndex, previousPageData) => {
      // First page — no cursor needed
      if (pageIndex === 0) return "/api/products?limit=20";
 
      // No more pages
      if (!previousPageData?.nextCursor) return null;
 
      // Cursor-based pagination
      return `/api/products?cursor=${previousPageData.nextCursor}&limit=20`;
    },
    fetcher
  );
 
  const products = data?.flatMap((page) => page.items) ?? [];
  const hasMore = data?.[data.length - 1]?.nextCursor !== null;
 
  if (isLoading) return <div>Loading products...</div>;
 
  return (
    <div>
      <div className="grid grid-cols-3 gap-4">
        {products.map((product) => (
          <div key={product.id} className="border p-4 rounded">
            <h3>{product.name}</h3>
            <p>${product.price}</p>
          </div>
        ))}
      </div>
      {hasMore && (
        <button onClick={() => setSize(size + 1)}>Load More</button>
      )}
    </div>
  );
}

Deep Dive

How It Works

  • useSWRInfinite manages an array of pages, where each page is the result of a separate fetch.
  • The getKey(pageIndex, previousPageData) function is called for each page index starting from 0.
  • Returning null from getKey tells SWR to stop fetching further pages.
  • data is an array of arrays (or array of page objects), one entry per loaded page.
  • setSize(n) sets how many pages should be loaded. SWR fetches any pages not yet in cache.
  • Each page has its own cache entry, so individual pages can revalidate independently.

Variations

Offset-based pagination:

const getKey = (pageIndex: number) => `/api/items?offset=${pageIndex * 20}&limit=20`;

With Intersection Observer for infinite scroll:

import { useRef, useCallback } from "react";
 
function InfiniteList() {
  const { data, size, setSize } = useSWRInfinite(getKey, fetcher);
  const observer = useRef<IntersectionObserver>();
 
  const lastElementRef = useCallback(
    (node: HTMLElement | null) => {
      if (observer.current) observer.current.disconnect();
      observer.current = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting) {
          setSize((s) => s + 1);
        }
      });
      if (node) observer.current.observe(node);
    },
    [setSize]
  );
 
  const items = data?.flat() ?? [];
 
  return (
    <ul>
      {items.map((item, i) => (
        <li key={item.id} ref={i === items.length - 1 ? lastElementRef : undefined}>
          {item.name}
        </li>
      ))}
    </ul>
  );
}

Parallel page fetching:

const { data } = useSWRInfinite(getKey, fetcher, {
  parallel: true, // Fetch all pages in parallel on revalidation
});

TypeScript Notes

  • useSWRInfinite<Data, Error> generics type each page in the data array.
  • data is Data[] | undefined — an array of pages.
  • The getKey function receives (pageIndex: number, previousPageData: Data | null).
const { data } = useSWRInfinite<Product[]>(getKey, fetcher);
// data: Product[][] | undefined
const allProducts = data?.flat() ?? [];
// allProducts: Product[]

Gotchas

  • data is an array of pages, not a flat list. You almost always need .flat() or .flatMap() to get a single array.
  • Setting revalidateFirstPage: false can prevent unnecessary re-fetches of page 0 when loading additional pages, but means page 0 data could become stale.
  • setSize triggers fetches for any pages not yet cached. Setting setSize(1) does not discard pages 2+ from the cache; it only controls what is rendered.
  • Cursor-based pagination requires the previous page's data to compute the next key, so pages always load sequentially (no parallel fetch on initial load).
  • Mutating paginated data is complex. Prefer revalidating the whole list after a mutation rather than manually updating individual pages.

Alternatives

ApproachProsCons
useSWRInfiniteBuilt into SWR, page-level cachingComplex getKey logic, array-of-arrays data shape
Manual useSWR per pageSimpler mental modelNo built-in "load more" or page tracking
React Query useInfiniteQuerySimilar API, built-in getNextPageParamDifferent library ecosystem
Server Components with searchParamsNo client JS, SEO-friendlyFull page reload per page change

FAQs

Why does useSWRInfinite return an array of arrays instead of a flat list?

Each page is fetched as a separate request and cached independently. data is an array of page results. You need .flat() or .flatMap() to produce a single flat list for rendering.

How does the getKey function control pagination?

getKey(pageIndex, previousPageData) is called for each page starting at index 0. It returns the URL for that page. Return null to signal there are no more pages to fetch.

What is the difference between offset-based and cursor-based pagination with useSWRInfinite?
  • Offset-based: getKey computes the URL from pageIndex (e.g., ?offset=${pageIndex * 20}).
  • Cursor-based: getKey reads a cursor from previousPageData (e.g., ?cursor=${previousPageData.nextCursor}).
  • Cursor-based requires sequential loading; offset-based can support parallel fetching.
How do I implement infinite scroll with useSWRInfinite?

Use an IntersectionObserver on the last rendered element. When it becomes visible, call setSize(s => s + 1) to load the next page.

What does the parallel option do in useSWRInfinite?
const { data } = useSWRInfinite(getKey, fetcher, {
  parallel: true,
});

It fetches all loaded pages in parallel during revalidation. Note: this only works for offset-based pagination since cursor-based requires previous page data.

Gotcha: Does setSize(1) discard cached data for other pages?

No. setSize(1) only controls how many pages are rendered, not what is in the cache. Pages 2+ remain cached. This can lead to confusion if you expect reducing size to clear old data.

How should I handle mutations with paginated data?

Prefer revalidating the entire list after a mutation rather than manually updating individual pages. Manual page-level cache updates are complex and error-prone with useSWRInfinite.

Gotcha: What happens if I set revalidateFirstPage: false?

SWR skips re-fetching page 0 when additional pages are loaded. This reduces network requests but means page 0 data can become stale without the user realizing it.

How do I type useSWRInfinite with TypeScript generics?
const { data } = useSWRInfinite<Product[]>(getKey, fetcher);
// data: Product[][] | undefined
const allProducts = data?.flat() ?? [];
// allProducts: Product[]

The generic types each page in the data array, so data becomes Data[].

How do I detect when there are no more pages to load?

Check the last page's data. If it has fewer items than the page limit or is empty, you have reached the end:

const isEmpty = data?.[0]?.length === 0;
const isReachingEnd =
  isEmpty || (data && data[data.length - 1]?.length < PAGE_SIZE);