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
useSWRInfinitemanages 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
nullfromgetKeytells SWR to stop fetching further pages. datais 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.dataisData[] | undefined— an array of pages.- The
getKeyfunction receives(pageIndex: number, previousPageData: Data | null).
const { data } = useSWRInfinite<Product[]>(getKey, fetcher);
// data: Product[][] | undefined
const allProducts = data?.flat() ?? [];
// allProducts: Product[]Gotchas
datais an array of pages, not a flat list. You almost always need.flat()or.flatMap()to get a single array.- Setting
revalidateFirstPage: falsecan prevent unnecessary re-fetches of page 0 when loading additional pages, but means page 0 data could become stale. setSizetriggers fetches for any pages not yet cached. SettingsetSize(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
| Approach | Pros | Cons |
|---|---|---|
| useSWRInfinite | Built into SWR, page-level caching | Complex getKey logic, array-of-arrays data shape |
| Manual useSWR per page | Simpler mental model | No built-in "load more" or page tracking |
| React Query useInfiniteQuery | Similar API, built-in getNextPageParam | Different library ecosystem |
| Server Components with searchParams | No client JS, SEO-friendly | Full 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:
getKeycomputes the URL frompageIndex(e.g.,?offset=${pageIndex * 20}). - Cursor-based:
getKeyreads a cursor frompreviousPageData(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);