Skip to content

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 ​

  1. SSR: The first page is fetched on the server via useAsyncData and hydrated on the client
  2. Pagination: getNextPageParam extracts the cursor/offset for the next request
  3. Accumulation: Each fetchNextPage() appends to the pages array
  4. End detection: When getNextPageParam returns undefined, hasNextPage becomes false

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 ​

Released under the MIT License.