Skip to content

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.

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:

  • edges is the list — but each entry is an edge, not a bare node.
  • node is the actual item (a Post). Wrapping it in an edge leaves room for per-edge data.
  • cursor is an opaque string identifying that edge’s position in this connection. Treat it as a black box; never parse it.
  • pageInfo carries paging metadata. hasNextPage says whether more items exist; endCursor is the cursor of the last edge, which you feed into the next request.

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 wraps each item in an edge with a cursor; pageInfo tells you how to fetch the next page.

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.

JavaScript

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.

In a Relay connection, what does each entry in edges contain?
How do you fetch the page after the current one?
Why are cursors more robust than offsets when a list changes during browsing?