From harness-claude
Implements cursor-based and offset pagination in GraphQL using Relay connection spec. For paginated lists, feeds, search results, admin tables, infinite scroll.
npx claudepluginhub intense-visions/harness-engineering --plugin harness-claudeThis skill uses the workspace's default tool permissions.
> Implement cursor-based and offset pagination in GraphQL using the Relay connection specification
Implements Relay's cursor-based GraphQL pagination in React apps using usePaginationFragment for infinite scroll, load more, and automatic cache updates.
Guides cursor-based pagination with opaque tokens to prevent page drift from inserts/deletes in feeds, logs, timelines, and large datasets. Covers forward/backward traversal.
Designs GraphQL schemas with Relay pagination, TypeScript resolvers using DataLoader for N+1 prevention, subscriptions, mutations, and error payloads.
Share bugs, ideas, or general feedback.
Implement cursor-based and offset pagination in GraphQL using the Relay connection specification
Connection/Edge/PageInfo pattern is the industry standard for GraphQL pagination.type Query {
users(first: Int, after: String, last: Int, before: String): UserConnection!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
function encodeCursor(id: string): string {
return Buffer.from(`cursor:${id}`).toString('base64');
}
function decodeCursor(cursor: string): string {
const decoded = Buffer.from(cursor, 'base64').toString('utf-8');
return decoded.replace('cursor:', '');
}
first/after (forward) and last/before (backward) pagination.const resolvers = {
Query: {
users: async (_parent, { first, after, last, before }, { db }) => {
const limit = first ?? last ?? 20;
const afterId = after ? decodeCursor(after) : null;
const beforeId = before ? decodeCursor(before) : null;
const users = await db.users.findPaginated({
limit: limit + 1, // fetch one extra to determine hasNextPage
afterId,
beforeId,
direction: last ? 'backward' : 'forward',
});
const hasMore = users.length > limit;
const nodes = hasMore ? users.slice(0, limit) : users;
if (last) nodes.reverse();
return {
edges: nodes.map((user) => ({
node: user,
cursor: encodeCursor(user.id),
})),
pageInfo: {
hasNextPage: first ? hasMore : false,
hasPreviousPage: last ? hasMore : false,
startCursor: nodes[0] ? encodeCursor(nodes[0].id) : null,
endCursor: nodes[nodes.length - 1] ? encodeCursor(nodes[nodes.length - 1].id) : null,
},
};
},
},
};
Include totalCount when clients need it (e.g., for "showing 1-20 of 342"). Be aware this requires a separate COUNT(*) query, which can be expensive on large tables.
For simple use cases, offset pagination is acceptable. Use it for admin dashboards, data tables, or any context where "jump to page N" is needed and data does not change frequently.
type Query {
users(offset: Int, limit: Int): UserList!
}
type UserList {
items: [User!]!
totalCount: Int!
hasMore: Boolean!
}
fetchMore to load additional pages.const { data, fetchMore } = useQuery(GET_USERS, { variables: { first: 20 } });
const loadMore = () => {
fetchMore({
variables: { after: data.users.pageInfo.endCursor },
updateQuery: (prev, { fetchMoreResult }) => ({
users: {
...fetchMoreResult.users,
edges: [...prev.users.edges, ...fetchMoreResult.users.edges],
},
}),
});
};
first/limit. Default to 20, cap at 100. This prevents clients from requesting unbounded result sets.const limit = Math.min(first ?? 20, 100);
@connection directive (Apollo Client) to give paginated fields a stable cache key when the same field is queried with different pagination arguments.Cursor vs. offset trade-offs:
WHERE id > cursor), no "page drift." Cannot jump to arbitrary pages.OFFSET 10000 scans and discards rows), unstable when items are inserted/deleted between pages.Cursor implementation strategies:
WHERE id > :cursor ORDER BY id — simple, efficient, works when ordering by primary keyWHERE created_at > :cursor ORDER BY created_at — use a composite cursor (timestamp + id) for tiesPerformance considerations:
limit + 1 to determine hasNextPage without a separate count queryWHERE clause must hit an index)totalCount separately if it is expensive and does not need to be real-timeWHERE clause dynamicallyApollo Client cache integration: Apollo's offsetLimitPagination() and relayStylePagination() type policies handle merging paginated results in the cache automatically.
https://relay.dev/graphql/connections.htm