Skip to main content
Paginated queries allow users to navigate through data one page at a time. Unlike infinite queries, paginated queries replace the current page with the next or previous page.

Basic Pagination

Implement pagination using useQuery with a page parameter in the query key:
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'

function Projects() {
  const [page, setPage] = useState(0)

  const { data, status, error, isFetching } = useQuery({
    queryKey: ['projects', page],
    queryFn: async () => {
      const response = await fetch(`/api/projects?page=${page}`)
      return await response.json()
    },
  })

  if (status === 'pending') return <div>Loading...</div>
  if (status === 'error') return <div>Error: {error.message}</div>

  return (
    <div>
      {data.projects.map((project) => (
        <p key={project.id}>{project.name}</p>
      ))}
      <div>
        <button
          onClick={() => setPage((old) => Math.max(old - 1, 0))}
          disabled={page === 0}
        >
          Previous Page
        </button>
        <button
          onClick={() => setPage((old) => old + 1)}
          disabled={!data?.hasMore}
        >
          Next Page
        </button>
      </div>
      {isFetching ? <span>Loading...</span> : null}
    </div>
  )
}
Each page is cached separately based on its unique query key ['projects', page]. This means navigating back to a previously viewed page will be instant if the data is still in cache.

Keeping Previous Data

Use placeholderData with keepPreviousData to prevent UI flickering when transitioning between pages:
import { useQuery, keepPreviousData } from '@tanstack/react-query'

function Projects() {
  const [page, setPage] = useState(0)

  const { data, isPlaceholderData } = useQuery({
    queryKey: ['projects', page],
    queryFn: () => fetchProjects(page),
    placeholderData: keepPreviousData,
  })

  return (
    <div>
      {data.projects.map((project) => (
        <p key={project.id}>{project.name}</p>
      ))}
      <button
        onClick={() => setPage((old) => old + 1)}
        disabled={isPlaceholderData || !data?.hasMore}
      >
        Next Page
      </button>
    </div>
  )
}
isPlaceholderData will be true when showing previous data while the new page is loading. Use this to disable navigation buttons during transitions.

Prefetching Next Page

Improve perceived performance by prefetching the next page before the user clicks:
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'
import { useEffect, useState } from 'react'

const fetchProjects = async (page = 0) => {
  const response = await fetch(`/api/projects?page=${page}`)
  return await response.json()
}

function Projects() {
  const queryClient = useQueryClient()
  const [page, setPage] = useState(0)

  const { data, isPlaceholderData } = useQuery({
    queryKey: ['projects', page],
    queryFn: () => fetchProjects(page),
    placeholderData: keepPreviousData,
    staleTime: 5000,
  })

  // Prefetch the next page
  useEffect(() => {
    if (!isPlaceholderData && data?.hasMore) {
      queryClient.prefetchQuery({
        queryKey: ['projects', page + 1],
        queryFn: () => fetchProjects(page + 1),
      })
    }
  }, [data, isPlaceholderData, page, queryClient])

  return (
    <div>
      {data.projects.map((project) => (
        <p key={project.id}>{project.name}</p>
      ))}
      <div>Current Page: {page + 1}</div>
      <button
        onClick={() => setPage((old) => Math.max(old - 1, 0))}
        disabled={page === 0}
      >
        Previous Page
      </button>
      <button
        onClick={() => setPage((old) => (data?.hasMore ? old + 1 : old))}
        disabled={isPlaceholderData || !data?.hasMore}
      >
        Next Page
      </button>
    </div>
  )
}
1

Check placeholder status

Only prefetch when not showing placeholder data to avoid prefetching the wrong page.
2

Check if more pages exist

Use your API’s hasMore or similar flag to determine if there’s a next page.
3

Prefetch the next page

Use queryClient.prefetchQuery() to fetch and cache the next page in the background.

Cursor-Based Pagination

For APIs using cursor-based pagination instead of page numbers:
function Projects() {
  const [cursor, setCursor] = useState(null)

  const { data } = useQuery({
    queryKey: ['projects', cursor],
    queryFn: async () => {
      const url = cursor
        ? `/api/projects?cursor=${cursor}`
        : '/api/projects'
      const response = await fetch(url)
      return await response.json()
    },
    placeholderData: keepPreviousData,
  })

  return (
    <div>
      {data.projects.map((project) => (
        <p key={project.id}>{project.name}</p>
      ))}
      <button
        onClick={() => setCursor(data.nextCursor)}
        disabled={!data?.nextCursor}
      >
        Next Page
      </button>
    </div>
  )
}

Offset-Based Pagination

For APIs using offset and limit:
function Projects() {
  const [offset, setOffset] = useState(0)
  const limit = 10

  const { data } = useQuery({
    queryKey: ['projects', offset, limit],
    queryFn: async () => {
      const response = await fetch(
        `/api/projects?offset=${offset}&limit=${limit}`
      )
      return await response.json()
    },
    placeholderData: keepPreviousData,
  })

  return (
    <div>
      {data.projects.map((project) => (
        <p key={project.id}>{project.name}</p>
      ))}
      <button
        onClick={() => setOffset((old) => Math.max(old - limit, 0))}
        disabled={offset === 0}
      >
        Previous
      </button>
      <button
        onClick={() => setOffset((old) => old + limit)}
        disabled={!data?.hasMore}
      >
        Next
      </button>
    </div>
  )
}
Implement traditional page number navigation:
function Projects() {
  const [page, setPage] = useState(0)
  const pageSize = 10

  const { data } = useQuery({
    queryKey: ['projects', page],
    queryFn: () => fetchProjects(page),
    placeholderData: keepPreviousData,
  })

  const totalPages = data ? Math.ceil(data.total / pageSize) : 0

  return (
    <div>
      {data?.projects.map((project) => (
        <p key={project.id}>{project.name}</p>
      ))}
      <div>
        {Array.from({ length: totalPages }, (_, i) => (
          <button
            key={i}
            onClick={() => setPage(i)}
            disabled={page === i}
          >
            {i + 1}
          </button>
        ))}
      </div>
    </div>
  )
}
Be careful when rendering many page number buttons. Consider showing only a subset of pages (e.g., first, last, current, and nearby pages) for large datasets.

Stale Time Configuration

Control how long paginated data stays fresh:
const { data } = useQuery({
  queryKey: ['projects', page],
  queryFn: () => fetchProjects(page),
  placeholderData: keepPreviousData,
  staleTime: 5000, // Data stays fresh for 5 seconds
})
Setting a staleTime prevents unnecessary refetches when navigating between pages quickly. Each page’s staleness is tracked independently.

Comparing Pagination Strategies

FeatureRegular PaginationInfinite Queries
Use CaseTraditional page navigationLoad more / infinite scroll
Data StructureSingle page at a timeAll loaded pages
Memory UsageLower (one page)Higher (all pages)
NavigationBi-directional by defaultForward-focused
UI PatternPage numbers, prev/nextLoad more button, auto-scroll
Choose regular pagination for dashboards and admin panels where users need to jump between pages. Use infinite queries for feeds and content streams where users scroll continuously.

Build docs developers (and LLMs) love