Skip to content

Cursor-based Pagination ​

Cursor-based pagination uses an opaque identifier (a cursor) to mark a position in the list. The server returns the items after a given cursor along with a new cursor for the next page:

graphql
type Query {
  feed(cursor: String, limit: Int!): FeedConnection!
}

type FeedConnection {
  items: [FeedItem!]!
  nextCursor: String
  hasMore: Boolean!
}

type FeedItem {
  id: ID!
  message: String!
}

Cursors are stable across mutations: adding or removing items between page fetches does not corrupt subsequent pages because each cursor points at a specific item, not an absolute position.

Two flavors of cursor pagination ​

This page covers two common shapes:

  1. Separate cursor and items. The response includes a nextCursor field alongside the items array. Recommended for most cases.
  2. Relay-style connections. Items wrapped in edges with per-item cursors plus a pageInfo object. Useful when consuming Relay-compatible APIs.

For the underlying merge and read mechanics, see Apollo's Cursor-based pagination.

Separate cursor: configuration ​

A simple merge function appends the new items and tracks the most recent cursor:

ts
import { ApolloClient, InMemoryCache } from '@apollo/client'

const apolloClient = new ApolloClient({
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          feed: {
            keyArgs: false, // Single merged list, regardless of cursor argument
            merge(existing, incoming, { readField }) {
              const items = existing ? { ...existing.items } : {}
              incoming.items.forEach((item: any) => {
                items[readField('id', item) as string] = item
              })
              return {
                nextCursor: incoming.nextCursor,
                hasMore: incoming.hasMore,
                items,
              }
            },
            read(existing) {
              if (!existing)
                return undefined
              return {
                nextCursor: existing.nextCursor,
                hasMore: existing.hasMore,
                items: Object.values(existing.items),
              }
            },
          },
        },
      },
    },
  }),
  // ...
})

Storing items in a map keyed by id makes duplicate handling automatic: if the same id arrives again, it overwrites itself rather than duplicating in the list.

Separate cursor: usage ​

vue
<script setup lang="ts">
const FEED_QUERY: TypedDocumentNode<{ feed: { items: Array<{ id: string, message: string }>, nextCursor: string | null, hasMore: boolean } }, { cursor?: string | null, limit: number }>
  = gql`
    query Feed($cursor: String, $limit: Int!) {
      feed(cursor: $cursor, limit: $limit) {
        items {
          id
          message
        }
        nextCursor
        hasMore
      }
    }
  `

const { current, fetchMore } = useQuery(FEED_QUERY, {
  variables: { limit: 10 },
})

function loadMore() {
  if (current.value.resultState !== 'complete')
    return
  if (!current.value.result.feed.hasMore)
    return

  fetchMore({
    variables: {
      cursor: current.value.result.feed.nextCursor,
      limit: 10,
    },
  })
}
</script>

<template>
  <ul v-if="current.resultState === 'complete'">
    <li v-for="item in current.result.feed.items" :key="item.id">
      {{ item.message }}
    </li>
  </ul>
  <button
    v-if="current.resultState === 'complete' && current.result.feed.hasMore"
    @click="loadMore"
  >
    Load more
  </button>
</template>
vue
<script setup lang="ts">
import { ApolloQuery } from '@vue/apollo-components'
</script>

<template>
  <ApolloQuery :query="Feed" :variables="{ limit: 10 }">
    <template #data="{ data, loading, fetchMore }">
      <ul>
        <li v-for="item in data.feed.items" :key="item.id">
          {{ item.message }}
        </li>
      </ul>
      <button
        v-if="data.feed.hasMore"
        :disabled="loading"
        @click="fetchMore({ variables: { cursor: data.feed.nextCursor, limit: 10 } })"
      >
        Load more
      </button>
    </template>
  </ApolloQuery>
</template>

#data only renders once there is a result, so data.feed.hasMore and data.feed.nextCursor are safe to read without checking for one first.

Each fetchMore call passes the cursor from the previous page. Apollo merges the new items into the cached list using the merge function configured above.

Relay-style connections ​

For APIs that follow the Relay Cursor Connections Specification, Apollo Client ships a helper:

ts
import { InMemoryCache } from '@apollo/client'
import { relayStylePagination } from '@apollo/client/utilities'

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        comments: relayStylePagination(),
      },
    },
  },
})

The helper handles edges, pageInfo, and the standard cursor naming.

Usage:

vue
<script setup lang="ts">
const COMMENTS_QUERY: TypedDocumentNode<{ comments: { edges: Array<{ node: { id: string, text: string } }>, pageInfo: { endCursor: string | null, hasNextPage: boolean } } }, { cursor?: string | null }>
  = gql`
    query Comments($cursor: String) {
      comments(first: 10, after: $cursor) {
        edges {
          node {
            id
            text
          }
        }
        pageInfo {
          endCursor
          hasNextPage
        }
      }
    }
  `

const { current, fetchMore } = useQuery(COMMENTS_QUERY)

function loadMore() {
  if (current.value.resultState !== 'complete')
    return
  if (!current.value.result.comments.pageInfo.hasNextPage)
    return

  fetchMore({
    variables: {
      cursor: current.value.result.comments.pageInfo.endCursor,
    },
  })
}
</script>

<template>
  <ul v-if="current.resultState === 'complete'">
    <li v-for="edge in current.result.comments.edges" :key="edge.node.id">
      {{ edge.node.text }}
    </li>
  </ul>
  <button @click="loadMore">
    Load more
  </button>
</template>
vue
<script setup lang="ts">
import { ApolloQuery } from '@vue/apollo-components'
</script>

<template>
  <ApolloQuery :query="Comments">
    <template #data="{ data, fetchMore }">
      <ul>
        <li v-for="edge in data.comments.edges" :key="edge.node.id">
          {{ edge.node.text }}
        </li>
      </ul>
      <button
        v-if="data.comments.pageInfo.hasNextPage"
        @click="fetchMore({ variables: { cursor: data.comments.pageInfo.endCursor } })"
      >
        Load more
      </button>
    </template>
  </ApolloQuery>
</template>

Inserting new items into a paginated list ​

The cache cannot tell which page a newly-created item belongs to, so a mutation does not automatically appear in the paginated list. Options:

  1. Refetch the first page after creating an item.
  2. Write to the cache through cache.modify to insert the item into the merged list directly.
  3. For optimistic UI, use an optimisticResponse plus an update callback that inserts into the merged list.

See Cache Updates for the insertion patterns.

<ApolloMutation> has props only for mutation, variables and clientId. update, refetchQueries and optimisticResponse all reach useMutation through the options prop, which takes the full useMutation.Options object:

template
<ApolloMutation
  v-slot="{ mutate }"
  :mutation="PostFeedItem"
  :options="{ optimisticResponse: pendingItem, update: insertIntoFeed }"
  @error="console.error"
>
  <button @click="mutate({ variables: { message } })">
    Post
  </button>
</ApolloMutation>

Key arguments ​

If your paginated field also accepts filter or sort arguments, mark those as keyArgs so each filter value gets its own merged list:

ts
const fields = {
  feed: {
    keyArgs: ['filter', 'sortOrder'],
    // ...
  },
}

Otherwise, switching filters would merge incompatible pages together.

Next steps ​

Released under the MIT License.