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 nextCursorfetchPosts({ pageParam })data.pagesexport 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