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.

A cursor that is secretly an offset

Symptoms:

  • the cursor is just base64(offset) — not stable
  • once an item is deleted, an old cursor points at the wrong position
  • Fix: build an opaque cursor from a unique identifier, e.g. base64(id) or base64(createdAt:id)

An edge type with no custom fields

Symptoms:

  • Edge only has node and cursor — the edge’s own potential is unused
  • an edge could carry relationship metadata, like joinedAt for a user-in-group edge

💡 Real-world examples

GitHub:

  • every list uses the Relay connection spec: repository.issues(first: 10, after: $cursor) (GitHub GraphQL pagination guide)
  • pagination works consistently across issues, PRs, and comments

Shopify:

  • the Storefront API uses connections everywhere — products, orders, collections (Shopify pagination guide)
  • the Hydrogen client library ships a Pagination component and getPaginationVariables helper that work with the connection spec automatically (Hydrogen pagination docs)
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?