Relay Connections
Offset pagination addresses positions, which shift when the list changes. Cursor pagination fixes this by addressing items: each item gets an opaque cursor, and you ask for “the next N items after this cursor.” Because a cursor names a specific item rather than a slot, inserts and deletes elsewhere in the list no longer duplicate or skip results.
The most widely adopted shape for cursor pagination is the Relay Connection specification. It standardizes the field names so that tooling and clients can paginate any list the same way.
The connection shape
Section titled “The connection shape”A connection wraps a list in three nested levels. Instead of returning [Post!]! directly, a list field returns a PostConnection:
type Query { posts(first: Int, after: String): PostConnection!}
type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo!}
type PostEdge { node: Post! cursor: String!}
type PageInfo { hasNextPage: Boolean! endCursor: String}Decoding the layers:
edgesis the list — but each entry is an edge, not a bare node.nodeis the actual item (aPost). Wrapping it in an edge leaves room for per-edge data.cursoris an opaque string identifying that edge’s position in this connection. Treat it as a black box; never parse it.pageInfocarries paging metadata.hasNextPagesays whether more items exist;endCursoris the cursor of the last edge, which you feed into the next request.
first and after
Section titled “first and after”Forward pagination uses two arguments. first: N asks for the next N items. after: cursor says “start after this cursor.” The first page omits after:
{ posts(first: 2) { edges { node { title } cursor } pageInfo { hasNextPage endCursor } }}To get the next page, you take pageInfo.endCursor from the response and pass it back as after. (The spec also defines last/before for backward paging, the mirror image of first/after.)
flowchart TD Conn["PostConnection"] Conn --> Edges["edges: [PostEdge!]!"] Conn --> PI["pageInfo: PageInfo!"] Edges --> Edge["PostEdge"] Edge --> Node["node: Post!"] Edge --> Cursor["cursor: String! (opaque)"] PI --> HNP["hasNextPage: Boolean!"] PI --> EC["endCursor: String — feed into next 'after'"]
A connection, run for real
Section titled “A connection, run for real”The runner below builds a real PostConnection schema and resolves first/after by encoding each item’s id into a cursor. Run it to fetch page one; then copy the endCursor from the output into the after: argument and re-run to walk forward.
The output gives two edges, each with a node and an opaque cursor, plus a pageInfo saying hasNextPage: true and handing back an endCursor. Pass that endCursor as after and you get the next two — and because the cursor names an item, not a position, inserting a new post at the top of the list would not make page two repeat or skip anything. That stability is the whole reason connections exist.