Next.jsReact

Understanding React Query: useInfiniteQuery

Taeeun Kim
2025년 6월 6일

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.


1. What is useInfiniteQuery Really Doing?

The useInfiniteQuery hook:

  • Manages a list of paginated results
  • Keeps track of what the next page is
  • Provides fetchNextPage() to load more
  • Merges all pages together internally

Key Config Options:

useInfiniteQuery({
  queryKey: [...],
  queryFn: fetchFunction,
  initialPageParam: undefined,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
})

What It Expects Internally:

{
  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).


2. How initialData Works

When 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.

  • pages: what queryFn returns for each page
  • pageParams: the pageParam that was passed into each queryFn call

When 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: [...]
  }
}

3. How Pagination Actually Works

Here is a simplified flow:

  1. initialData.pages = [firstPage] gets rendered immediately
  2. When user clicks "Load More":
    • React Query looks at the last page (lastPage = data.pages.at(-1))
    • Calls getNextPageParam(lastPage) to get the nextCursor
    • Passes that into fetchPosts({ pageParam })
    • Adds the result to data.pages

Example API:

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.

Full Example

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) ?? [];

4. My Confusions Answered

Q: Will it crash if 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.

Q: Why is 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.

Q: What is 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.


5. Real-World Example: Blog Post List

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.


6. Conclusion

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 time
  • getNextPageParam tells React Query how to paginate
  • initialData.pages allows hydration without refetching