Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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>
);
}"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>
);
}useSWRInfinite manages an array of pages, where each page is the result of a separate fetch.getKey(pageIndex, previousPageData) function is called for each page index starting from 0.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.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
});useSWRInfinite<Data, Error> generics type each page in the data array.data is Data[] | undefined - an array of pages.getKey function receives (pageIndex: number, previousPageData: Data | null).const { data } = useSWRInfinite<Product[]>(getKey, fetcher);
// data: Product[][] | undefined
const allProducts = data?.flat() ?? [];
// allProducts: Product[]data is an array of pages, not a flat list. You almost always need .flat() or .flatMap() to get a single array.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.| 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 |
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.
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.
getKey computes the URL from pageIndex (e.g., ?offset=${pageIndex * 20}).getKey reads a cursor from previousPageData (e.g., ?cursor=${previousPageData.nextCursor}).Use an IntersectionObserver on the last rendered element. When it becomes visible, call setSize(s => s + 1) to load the next page.
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.
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.
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.
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.
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[].
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);Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥