Eden TanStack Query
Guides

Infinite Queries

Implement pagination with cursor-based infinite queries

Infinite Queries

Use infiniteQueryOptions() for cursor-based pagination with TanStack Query's useInfiniteQuery. The infinite-query methods are exposed only when the route declares a required or optional usable top-level cursor query field on a non-union query schema. Union query schemas are excluded because TypeScript cannot safely correlate a variant's remaining input with its cursor type through this API.

Server Route

A typical cursor-based pagination endpoint:

app.get('/posts', ({ query }) => {
  const { limit, cursor } = query
  const posts = getPosts({ limit, after: cursor })

  return {
    items: posts,
    nextCursor: posts.length === limit ? posts[posts.length - 1].id : null,
  }
}, {
  query: t.Object({
    limit: t.Number({ default: 10 }),
    cursor: t.Optional(t.String()),
  })
})

Basic Usage

import { useInfiniteQuery } from '@tanstack/react-query'
import { useEden } from './lib/eden'

function PostList() {
  const eden = useEden()

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery(
    eden.posts.get.infiniteQueryOptions(
      { limit: 10 },
      {
        getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
        initialCursor: null,
      }
    )
  )

  if (isLoading) return <div>Loading...</div>

  return (
    <div>
      {data?.pages.map((page) =>
        page.items.map((post) => (
          <article key={post.id}>
            <h2>{post.title}</h2>
          </article>
        ))
      )}

      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load More' : 'No more posts'}
      </button>
    </div>
  )
}

Pagination Options

getNextPageParam is required when using infiniteQueryOptions():

getNextPageParam -- determines the cursor for the next page. Return undefined to indicate there are no more pages:

{
  getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined
}

For an optional cursor field, initialCursor defaults to null. Treaty omits that null query value, which represents an absent optional cursor:

{
  initialCursor: null  // or 0, '', depending on your API
}

For a required cursor field, provide an explicit non-null value:

eden.posts.get.infiniteQueryOptions(
  { limit: 10 },
  {
    initialCursor: 'start',
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  }
)

This also applies when the required cursor schema includes null: Treaty omits null query values, so null cannot satisfy a required query property on the wire.

Input Parameters

The first argument is your query input (excluding cursor). The library automatically adds the cursor to each page request:

eden.posts.get.infiniteQueryOptions(
  { limit: 10, category: 'technology' },
  {
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    initialCursor: null,
  }
)

Path Parameters

Combine path parameters with pagination:

// GET /users/:id/posts
function UserPosts({ userId }: { userId: string }) {
  const eden = useEden()

  const { data } = useInfiniteQuery(
    eden.users({ id: userId }).posts.get.infiniteQueryOptions(
      { limit: 10 },
      {
        getNextPageParam: (page) => page.nextCursor ?? undefined,
        initialCursor: null,
      }
    )
  )
}

Infinite Query Key and Filter

Use infiniteQueryKey() and infiniteQueryFilter() for cache operations:

const queryClient = useQueryClient()
const eden = useEden()

// Get the infinite query key
const key = eden.posts.get.infiniteQueryKey({ limit: 10 })

// Match options that start from a non-default cursor
const resumedKey = eden.posts.get.infiniteQueryKey(
  { limit: 10 },
  { initialCursor: 'cursor-20' }
)

// Invalidate infinite query
queryClient.invalidateQueries({
  queryKey: eden.posts.get.infiniteQueryKey()
})

// Using infiniteQueryFilter
queryClient.invalidateQueries(
  eden.posts.get.infiniteQueryFilter({ category: 'tech' })
)

// Invalidate only the variant that starts from cursor-20
queryClient.invalidateQueries(
  eden.posts.get.infiniteQueryFilter(
    { category: 'tech' },
    { initialCursor: 'cursor-20' }
  )
)

Exact infinite-query keys include the initial cursor. Filters omit it on purpose and match every starting cursor for the selected input unless an initialCursor filter option is provided. A provided cursor is compared through the candidate query's key hash, so the default hash treats it as one complete value while a custom queryKeyHashFn retains its own cache identity. Route input remains partially matchable. With exact: true, omitting initialCursor targets only queries whose initial cursor is the default null on optional cursor routes. Required cursor routes allow omission only for broad filters with exact absent or false; pass an explicit non-null initialCursor for exact or cursor-specific filtering.

Conditional with skipToken

import { skipToken } from '@tanstack/react-query'

function UserPosts({ userId }: { userId: string | null }) {
  const eden = useEden()

  const { data } = useInfiniteQuery(
    eden.users({ id: userId ?? '' }).posts.get.infiniteQueryOptions(
      userId ? { limit: 10 } : skipToken,
      {
        getNextPageParam: (page) => page.nextCursor ?? undefined,
        initialCursor: null,
      }
    )
  )
}

Abort on Unmount

const { data } = useInfiniteQuery(
  eden.posts.get.infiniteQueryOptions(
    { limit: 10 },
    {
      getNextPageParam: (page) => page.nextCursor ?? undefined,
      initialCursor: null,
      eden: { abortOnUnmount: true },
    }
  )
)

On this page