When building a paginated list of blog posts (or any content) in a Next.js app, useInfiniteQuery
from @tanstack/react-query is a powerful tool. But using it correctly, especially with initial server-fetched data (like from getServerSideProps
or a React Server Component), can be confusing.
This guide clears up the core concepts and answers key questions about integrating infinite queries in a React/Next.js app with initial data hydration.
useInfiniteQuery
Really Doing?The useInfiniteQuery
hook:
fetchNextPage()
to load moreuseInfiniteQuery({
queryKey: [...],
queryFn: fetchFunction,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
{
pages: [page1, page2, ...],
pageParams: [param1, param2, ...]
}
Each page
should be the return value of your queryFn
, and each param
is the input used to get that page (typically a cursor).
initialData
WorksWhen using SSR or React Server Components, you might already have the first page of results. You don't want to refetch that on the client.
So you can hydrate it into the hook like this:
const initialData = use(postsPromise); // from server context
useInfiniteQuery({
...,
initialData: {
pages: [initialData],
pageParams: [undefined],
}
})
Even though initialData
is just one response object, you're wrapping it in pages: [...]
so that React Query treats it as the first page.
queryFn
returns for each pagepageParam
that was passed into each queryFn
callWhen using initialData
with useInfiniteQuery
, it must be in this structure:
{
pages: [/* array of pages (results of queryFn) */],
pageParams: [/* array of input params used to get each page */]
}
This is by design, because useInfiniteQuery
maintains internal state like this:
{
data: {
pages: [...],
pageParams: [...]
}
}
Here is a simplified flow:
initialData.pages = [firstPage]
gets rendered immediatelylastPage = data.pages.at(-1)
)getNextPageParam(lastPage)
to get the nextCursor
fetchPosts({ pageParam })
data.pages
export const getPublishedPosts = async ({
tag = 'all',
sort = 'latest',
pageSize = 2,
startCursor,
}: GetPublishedPostsParams): Promise<GetPublishedPostsResponse> => {
const response = await notion.databases.query({
database_id: process.env.NOTION_DATABASE_ID!,
page_size: pageSize,
start_cursor: startCursor, // undefined for first page
...filters
});
return {
posts: response.results.map(...),
hasMore: response.has_more,
nextCursor: response.next_cursor,
};
}
If startCursor
is undefined
, Notion returns the first page. So your first call is safe.
export interface GetPublishedPostsResponse {
posts: Post[];
hasMore: boolean;
nextCursor: string | null;
}
const fetchPosts = async ({ pageParam }: { pageParam: string | undefined }) => {
const params = new URLSearchParams();
if (tag) params.set('tag', tag);
if (sort) params.set('sort', sort);
if (pageParam) params.set('startCursor', pageParam);
const response = await fetch(`/api/posts?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch posts');
}
return response.json();
};
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['posts', tag, sort],
queryFn: fetchPosts, // runs fetchPosts({ pageParam which is lastPage.nextCursor })
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialData: {
pages: [initialData],
pageParams: [undefined],
},
});
const handleLoadMore = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
startCursor
is undefined?No. startCursor being undefined is expected for the first page. Your API (e.g. Notion) will just return the first page of results.
initialData.pages = [data]
valid?Because useInfiniteQuery expects pages: [...] to be an array of response objects. You're giving it the first one manually so it doesn't have to fetch again.
lastPage
in getNextPageParam
?It is the last element in data.pages, i.e., the last response you got. You extract nextCursor from it to get the next page.
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['posts', tag, sort],
queryFn: fetchPosts,
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialData: {
pages: [initialData],
pageParams: [undefined],
},
});
const allPosts = data.pages.flatMap(page => page.posts);
This gives you a flat list of posts to render, and a fetchNextPage()
method to load more posts as the user scrolls or clicks a button.
Using useInfiniteQuery
with initialData
is a pattern that makes infinite pagination seamless in modern Next.js apps. Once you understand how the queryFn
, getNextPageParam
, and initialData.pages
interact, it becomes a clean and powerful tool.
Just remember:
queryFn
handles one page at a timegetNextPageParam
tells React Query how to paginateinitialData.pages
allows hydration without refetching