Infinite Queries
useInfiniteActionQuery provides infinite scroll and cursor-based pagination with SSR support. The first page is fetched on the server, and subsequent pages are accumulated on the client.
Working example
See infinite scroll in the example repository.
Basic Usage
Server Action
Define a paginated GET action:
ts
// server/actions/list-todos.get.ts
import { z } from 'zod'
export default defineAction({
input: z.object({
pageParam: z.string().optional(),
limit: z.number().default(20),
}),
handler: async ({ input }) => {
const todos = await db.todo.findMany({
take: input.limit + 1,
cursor: input.pageParam ? { id: input.pageParam } : undefined,
})
const hasMore = todos.length > input.limit
const items = hasMore ? todos.slice(0, -1) : todos
return {
items,
nextCursor: hasMore ? items[items.length - 1].id : undefined,
}
},
})Client Component
vue
<script setup lang="ts">
import { listTodos } from '#actions'
const { pages, fetchNextPage, hasNextPage, isFetchingNextPage, pending } =
useInfiniteActionQuery(listTodos, undefined, {
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
</script>
<template>
<div v-if="pending">Loading...</div>
<template v-else>
<div v-for="(page, i) in pages" :key="i">
<div v-for="todo in page.items" :key="todo.id">
{{ todo.title }}
</div>
</div>
<button
v-if="hasNextPage"
:disabled="isFetchingNextPage"
@click="fetchNextPage()"
>
{{ isFetchingNextPage ? 'Loading more...' : 'Load More' }}
</button>
<p v-if="!hasNextPage">No more items</p>
</template>
</template>How It Works
- SSR: The first page is fetched on the server via
useAsyncDataand hydrated on the client - Pagination:
getNextPageParamextracts the cursor/offset for the next request - Accumulation: Each
fetchNextPage()appends to thepagesarray - End detection: When
getNextPageParamreturnsundefined,hasNextPagebecomesfalse
Pagination Strategies
Cursor-Based
The most common pattern -- use a unique, ordered field (like id or createdAt):
ts
const { pages, fetchNextPage } = useInfiniteActionQuery(
listItems,
undefined,
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
)Offset-Based
Use a numeric offset:
ts
const { pages, fetchNextPage } = useInfiniteActionQuery(
listItems,
undefined,
{
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
const totalFetched = allPages.reduce((sum, p) => sum + p.items.length, 0)
return totalFetched < lastPage.total ? totalFetched : undefined
},
},
)Page Number
Use a simple page number:
ts
const { pages, fetchNextPage } = useInfiniteActionQuery(
listItems,
undefined,
{
initialPageParam: 1,
getNextPageParam: (lastPage) => {
return lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined
},
},
)Reactive Input
When the input is reactive, changing it resets all pages and re-fetches from the first page:
vue
<script setup lang="ts">
import { searchItems } from '#actions'
const query = ref('')
const { pages, fetchNextPage, hasNextPage } = useInfiniteActionQuery(
searchItems,
() => ({ q: query.value }),
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
)
</script>
<template>
<input v-model="query" placeholder="Search..." />
<!-- pages reset when query changes -->
</template>Conditional Fetching
Use the enabled option to defer fetching until a condition is met:
ts
const categoryId = ref<number | null>(null)
const isReady = computed(() => categoryId.value !== null)
const { pages } = useInfiniteActionQuery(
listByCategory,
() => ({ categoryId: categoryId.value! }),
{
enabled: isReady,
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
)Infinite Scroll with Intersection Observer
Combine with IntersectionObserver for automatic loading:
vue
<script setup lang="ts">
import { listTodos } from '#actions'
const { pages, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteActionQuery(listTodos, undefined, {
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
const sentinel = ref<HTMLElement | null>(null)
onMounted(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage.value && !isFetchingNextPage.value) {
fetchNextPage()
}
})
if (sentinel.value) {
observer.observe(sentinel.value)
}
onUnmounted(() => observer.disconnect())
})
</script>
<template>
<div v-for="(page, i) in pages" :key="i">
<div v-for="item in page.items" :key="item.id">{{ item.title }}</div>
</div>
<div ref="sentinel" />
<p v-if="isFetchingNextPage">Loading more...</p>
</template>Next Steps
- useInfiniteActionQuery API -- Full API reference
- SSR Queries -- Single-page queries with
useActionQuery