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.
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:
- No
afterargument. 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. afterequals the existingendCursor. This is a normal continuation. Append incoming edges after existing ones.afterpoints 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.
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.
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:
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
afteras a continuation of the whole list. - Refetch doubles the list. A
refetch()sends the original variables withoutafter. Without case 1, the merge appends page one to itself. The earlyreturn incominghandles it. - Stale
hasNextPageafter deletes. If entries are unpublished while a reader pages, the last page may be short andhasNextPagemay still betrue. Use the incomingpageInfo, 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
whereinput must appear inkeyArgs. - Strapi’s pagination shape. Strapi v4’s GraphQL plugin returns
meta.paginationwith 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.
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.
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.