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 },
}
)
)