Cursor Pagination Merge Functions for CMS Lists in Apollo

Part of Apollo Client GraphQL Caching, this guide writes the field policy that turns a cursor-paginated CMS collection (Hygraph’s Relay connections, Strapi’s GraphQL pagination or a gateway that wraps Sanity) into a single growing list in the cache.

Offset pagination merges by index: page three starts at skip: 20. Cursor pagination has no index, only an opaque after token that points at the last edge of the previous page, so the merge function has to append in arrival order, dedupe edges that appear twice, and know when a request is a fresh first page rather than a continuation.

Without and with a merge policyWithout keyArgs and merge, each fetchMore page is stored under its own cache key and the list component only sees the latest page; with them, pages append into one edges array.Page 1first: 10Page 2after: YXJyYXk6OQposts(first:10)10 edgesposts(first:10,after:…)10 edgesposts20 edges, one listno policyno policykeyArgs + merge
The default behaviour stores each page separately, so "load more" replaces the list instead of extending it.

The Problem

A documentation site built on Hygraph lists articles with a “load more” button. The first click works in development, where the component re-renders from the fetchMore result directly. In production, after a route change and a return to the list, the button appears to do nothing: the cache holds page one under posts({"first":10}) and page two under posts({"after":"…","first":10}), and the component’s original query only reads the first key. Some teams patch it with updateQuery in every fetchMore call, but that pattern is deprecated and spreads merge logic across components.

The fix is a field policy on the list field that tells Apollo two things: which arguments define a different list (keyArgs), and how to combine an incoming page with what is already cached (merge).

How Cursor Merges Work

A Relay-style connection returns edges (each with a cursor and a node) and pageInfo with endCursor and hasNextPage. When fetchMore runs with after: endCursor, Apollo calls the field’s merge(existing, incoming, { args }). The function must return the combined connection. Three cases matter:

  1. No after argument. This is a first page, from an initial load or a refetch after a publish. Replace the existing edges instead of appending, or a refetch doubles the list.
  2. after equals the existing endCursor. This is a normal continuation. Append incoming edges after existing ones.
  3. after points into the middle of the list. This happens when an entry was inserted or deleted between requests. Truncate the existing edges after the matching cursor, then append, so the list stays in cursor order without duplicates.
Decision path inside the merge functionThe merge function checks whether after is absent, equals the current end cursor, or points into the list, and chooses replace, append or truncate-then-append.incoming pageargs.afterpresent?Replace edgesfirst pageafter ==endCursor?Appenddedupe by node refTruncate at cursorthen appendnoyesyesno
Handling the mid-list cursor case is what keeps the list correct when editors publish while a reader is paging.

Implementation

The helper below is a reusable field policy for any Relay-style connection field. It is typed against Apollo’s FieldPolicy, handles the three cases above, and dedupes by the normalized node reference so the same entry can never appear twice.

TypeScript
import type { FieldPolicy, Reference } from "@apollo/client";

interface Edge {
  __typename?: string;
  cursor: string;
  node: Reference;
}

interface PageInfo {
  __typename?: string;
  endCursor: string | null;
  hasNextPage: boolean;
}

interface Connection {
  __typename?: string;
  edges: Edge[];
  pageInfo: PageInfo;
}

interface ConnectionArgs {
  first?: number;
  after?: string | null;
}

export function cursorConnection(keyArgs: string[]): FieldPolicy<Connection, Connection, Connection> {
  return {
    // Only these arguments create a separate list; first/after never do.
    keyArgs,
    merge(existing, incoming, { args }) {
      const { after } = (args ?? {}) as ConnectionArgs;
      // Case 1: a first page (initial load or refetch) replaces the list.
      if (!existing || !after) return incoming;

      // Case 3: find where the requested cursor sits in the cached edges.
      const cutAt = existing.edges.findIndex((e) => e.cursor === after);
      const kept = cutAt >= 0 ? existing.edges.slice(0, cutAt + 1) : existing.edges;

      // Dedupe by the normalized node reference, not by cursor strings,
      // because cursors are not stable across CMS publishes.
      const seen = new Set<string>();
      for (const e of kept) seen.add(e.node.__ref);
      const fresh = incoming.edges.filter((e) => !seen.has(e.node.__ref));

      return {
        ...incoming,
        edges: [...kept, ...fresh],
        pageInfo: incoming.pageInfo,
      };
    },
  };
}

// Usage in the cache constructor:
// typePolicies: {
//   Query: {
//     fields: {
//       posts: cursorConnection(["where", "orderBy", "locales", "stage"]),
//     },
//   },
// }

The keyArgs list is the part most teams get wrong. Anything that changes which entries belong in the list, such as filters, sort order, locale and content stage, must be listed, so the German list and the English list are separate cache entries. Anything that only changes which slice you are looking at (first, after, last, before) must not be listed, or every page becomes its own entry again.

On the component side, fetchMore needs nothing beyond the new variables:

TSX
import { useQuery } from "@apollo/client";
import { POSTS_QUERY } from "./queries";

export function PostList({ locale }: { locale: string }): JSX.Element {
  const { data, fetchMore, loading } = useQuery(POSTS_QUERY, {
    variables: { first: 10, locales: [locale] },
    notifyOnNetworkStatusChange: true,
  });
  const conn = data?.posts;
  return (
    <section>
      <ul>{conn?.edges.map((e: { node: { id: string; title: string } }) => <li key={e.node.id}>{e.node.title}</li>)}</ul>
      {conn?.pageInfo.hasNextPage && (
        <button type="button" disabled={loading} onClick={() => fetchMore({ variables: { after: conn.pageInfo.endCursor } })}>
          Load more
        </button>
      )}
    </section>
  );
}

Configuration Reference

Setting Recommended value Why
keyArgs filters, sort, locale, stage Each distinct combination is a distinct list.
first per page 10 to 25 Keeps CMS query complexity and response size low; Hygraph caps first at 100.
notifyOnNetworkStatusChange true Lets the button show a loading state during fetchMore.
nextFetchPolicy "cache-first" Prevents cache-and-network from refetching page one after every merge.
Dedupe key node __ref Entry identity survives cursor changes; cursor strings do not.

Gotchas & Edge Cases

  • Cursors change after a publish. Hygraph and most Relay implementations encode the sort position into the cursor, so publishing a new entry shifts every cursor after it. Dedupe by node, not by cursor, and treat an unknown after as a continuation of the whole list.
  • Refetch doubles the list. A refetch() sends the original variables without after. Without case 1, the merge appends page one to itself. The early return incoming handles it.
  • Stale hasNextPage after deletes. If entries are unpublished while a reader pages, the last page may be short and hasNextPage may still be true. Use the incoming pageInfo, never a cached one, as the policy above does.
  • Filters in variables but not in keyArgs. Switching a category filter then shows the previous category’s entries appended to the new one. Every argument used in the where input must appear in keyArgs.
  • Strapi’s pagination shape. Strapi v4’s GraphQL plugin returns meta.pagination with page numbers, not cursors. Use an offset merge for Strapi; this cursor policy applies only to Relay connections.

Testing the Merge Function

Field policies are plain functions, so the fastest test does not render anything: write a page into a real InMemoryCache, write the next page, and read the field back. The test below covers the three cases and runs in milliseconds under Vitest or Jest.

TypeScript
import { InMemoryCache, gql } from "@apollo/client";
import { describe, expect, it } from "vitest";
import { cursorConnection } from "./cursor-connection";

const QUERY = gql`
  query Posts($first: Int, $after: String, $locales: [Locale!]) {
    posts(first: $first, after: $after, locales: $locales) {
      edges { cursor node { id title } }
      pageInfo { endCursor hasNextPage }
    }
  }
`;

function page(ids: string[], end: string, more: boolean) {
  return {
    posts: {
      __typename: "PostConnection",
      edges: ids.map((id) => ({ __typename: "PostEdge", cursor: `c-${id}`, node: { __typename: "Post", id, title: `T${id}` } })),
      pageInfo: { __typename: "PageInfo", endCursor: end, hasNextPage: more },
    },
  };
}

describe("cursorConnection", () => {
  const make = () => new InMemoryCache({ typePolicies: { Query: { fields: { posts: cursorConnection(["locales"]) } } } });

  it("appends a continuation page and dedupes repeated nodes", () => {
    const cache = make();
    cache.writeQuery({ query: QUERY, variables: { first: 2, locales: ["en"] }, data: page(["1", "2"], "c-2", true) });
    cache.writeQuery({ query: QUERY, variables: { first: 2, after: "c-2", locales: ["en"] }, data: page(["2", "3"], "c-3", false) });
    const result = cache.readQuery<ReturnType<typeof page>>({ query: QUERY, variables: { first: 2, locales: ["en"] } });
    expect(result?.posts.edges.map((e) => e.node.id)).toEqual(["1", "2", "3"]);
  });

  it("replaces the list when a first page is written again", () => {
    const cache = make();
    cache.writeQuery({ query: QUERY, variables: { first: 2, locales: ["en"] }, data: page(["1", "2"], "c-2", true) });
    cache.writeQuery({ query: QUERY, variables: { first: 2, locales: ["en"] }, data: page(["9", "1"], "c-1", true) });
    const result = cache.readQuery<ReturnType<typeof page>>({ query: QUERY, variables: { first: 2, locales: ["en"] } });
    expect(result?.posts.edges.map((e) => e.node.id)).toEqual(["9", "1"]);
  });
});

A third test for the mid-list cursor case follows the same shape: write three pages, then write a continuation whose after is the cursor of the second edge, and assert that the list was truncated at that edge before appending.

Relationship to Server Rendering

When the first page is server-rendered and hydrated, the merge function runs on the client for every later page only. That keeps the hydrated HTML small, but it means the server and client must agree on keyArgs, or the hydrated list is written under a key the client never reads. Share one typePolicies module between the server client and the browser client; the SSR hydration guide shows the setup. Identity rules for the nodes themselves come from the typePolicies and keyFields guide.

Server-rendered first page, client-merged later pagesThe server renders page one and serializes the cache; the browser hydrates it, then fetchMore requests page two and the merge policy appends it.Server clientHTML payloadBrowser cacheHygraphposts(first: 10)10 edges + endCursorserialize cache.extract()restore on hydrateposts(first: 10, after: endCursor)10 edgesmerge: appenddedupe by __ref
Only later pages go through the client merge; page one arrives already normalized in the hydrated cache.

Frequently Asked Questions

Can I use Apollo’s built-in relayStylePagination helper instead?

Yes. relayStylePagination(keyArgs) from @apollo/client/utilities implements a similar merge and also supports backwards paging. Write your own when you need custom deduplication, or when the CMS connection is not strictly Relay-compliant, for example if pageInfo lacks startCursor.

Why does my list reset to the first page after navigating back?

The component’s query probably uses fetchPolicy: "network-only" or cache-and-network without a nextFetchPolicy, so it refetches page one on mount and case 1 replaces the list. Use cache-first for nextFetchPolicy, or keep the default and restore the scroll position from the cached edge count.

How large can a merged list get before it hurts performance?

Apollo handles a few thousand references per list comfortably. The practical limit is rendering, not caching: virtualize the list once it passes a few hundred rows, and cap “load more” in favour of search when readers page that deep.