How-to › Integrate beyond REST

How to implement Relay connections in a GraphQL schema#

Build the edges, cursors, and pageInfo a Relay-style connection promises, and get the page flags right rather than guessing them.

Audience
API producer
Level
intermediate
Topic
Integrate with GraphQL
Languages
TypeScript and JavaScript
Verified

A client paginates with endCursor and the last page never arrives, because hasNextPage is computed from whether the slice was full. A page that happens to end exactly on a boundary reports more rows, the client asks again, and gets an empty page with the same flag. The loop runs until somebody kills it.

What you get

You will end up with a connection whose edges carry cursors, and page flags derived from the cursors rather than from the slice length. This is for you if you are adding pagination to a GraphQL field and want clients to be able to walk it safely.

Short answer

Return a connection of edges, each carrying a node and an opaque cursor, plus a pageInfo object with hasNextPage, hasPreviousPage, startCursor and endCursor. Apply after and before first, then first or last, and read the page flags from what the cursors left behind. Refuse first and last together rather than picking one.

You will need

A GraphQL schema with a list field, and Node 22 or later. The shape is set by the Relay Cursor Connections specification, and the argument rules in its pagination algorithm section are the part most implementations get partly wrong.1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A simple limit and offset list fieldAn internal schema with small, stable lists and no Relay clientRows that shift under a paging client, and no convention for a tool to rely onA Relay or Apollo client expects connections
Connection fields you writeYou want the algorithm visible and the cursors under your controlThe algorithm to implement and to test, including the flags nobody checksA schema builder in use already generates them
Pothos relay pluginA code-first schema in TypeScript, where types come from the builderA plugin and its conventions, in return for the boilerplateThe schema is defined as SDL rather than in code
graphql-relay-js helpersYou want the reference implementation of the algorithm and nothing elseArray helpers that assume the whole list is in memoryRows come from a database you must page in SQL

The decision is whether the cursor means something to your storage. A helper that slices an array is correct and it loads every row first, which is fine for a list of 200 and wrong for a table with a million. Writing the connection yourself lets the cursor carry a sort key that becomes a WHERE clause, and it puts the page flags in your hands, where they can be tested. Start with the helper, and replace it the first time a list grows past what you want in memory.

Apply the cursors before the counts

The order of the four arguments is the algorithm, and reversing it produces flags that look right and are not.

const afterCursors = window.length
let sliced = window
if (first != null) sliced = sliced.slice(0, first)
if (last != null) sliced = sliced.slice(Math.max(0, sliced.length - last))

afterCursors is the count that matters. It is how many rows remain once after and before have been applied, and before first trims the page. Comparing it against first answers whether a next page exists. Comparing the returned page against the requested size does not, which is the bug in the opening paragraph.

Set the flags the specification actually asks for

hasPreviousPage is false on a forward page, and that is correct rather than lazy.

hasNextPage: first != null ? afterCursors > first : false,
hasPreviousPage: last != null ? afterCursors > last : false,

The specification permits false when the server was not asked to look in that direction, because counting backwards can cost a second query. Clients are written against that rule. A server that computes it anyway is doing extra work for a field nobody trusts, and one that returns an arbitrary value breaks a client that does.

Cursors are opaque, and making them opaque is not decoration. A base64 value stops a client constructing one from an offset, which means you can change the sort key later without breaking anyone who had reverse engineered the old one. Encode a version marker into the cursor as well, so a cursor issued by an older deployment can be recognized and refused rather than misread.

Check it worked

Page through seven rows and watch the flags.

node demo.mjs
first 3                    ids 1,2,3    next true  prev false
first 3 after page 1       ids 4,5,6    next true  prev false
first 3 from the tail      ids 6,7      next false prev false
last 2                     ids 6,7      next false prev true
first 3 and last 2         error: pass first or last, never both
after an unknown cursor    error: unknown cursor: bWV0ZXI6OTk5
cursor for meter 1: bWV0ZXI6MQ

Row three is the one to check in your own implementation. The page came back short, with two rows against a requested three, and hasNextPage is false. An implementation that compares the page length with the request gets this right by accident and gets the exact-boundary case wrong.

The last two rows matter as much. Both argument errors are refused rather than resolved into something plausible.2 A connection that silently ignores last when first is also present will return a page the client did not ask for, and the client has no way to tell.

node --test connection.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 119.767704

When it goes wrong

A client loops forever. hasNextPage is derived from the slice length. Derive it from the count remaining after the cursors are applied, and add a test for a page that ends on the boundary.

A cursor from yesterday returns nothing. The cursor encodes an offset and rows were inserted. Encode the sort key instead, and the cursor keeps meaning after a write.

Every query loads the whole table. The connection helper slices an array. Push after into the query as a WHERE clause, and fetch one row more than asked so the flag needs no second query. That extra row is the cheapest form of hasNextPage there is, and it is what most production implementations end up doing.

totalCount makes the field slow. A count over a large filtered table is a second expensive query. Make it optional in the schema, and let clients pay for it only when they ask. A field that is cheap on a list of 200 rows is the reason a dashboard takes nine seconds two years later.

When not to do this

Do not add connections to a schema no Relay-style client consumes. The shape costs two extra types per list and a layer of edges and node in every query. A plain list with a limit is easier to read, and moving to connections later is an additive change: add the new field, leave the old one, retire it when usage drops.

Do not expose a cursor that decodes to a database offset.3 Clients will read it, build their own, and then depend on a detail you meant to keep private. The next index change becomes a breaking one.

Do not compute both page flags on every request because it feels more correct. The second count is a real query against a real table, and the specification exists so you do not have to run it.

Last verified

Verified 2026-09-14 against Node 22.22.2. Both output blocks are what the preceding command printed.

Footnotes

  1. The PageInfo section says that on a backward page the server must report whether prior edges exist. On a forward page, it says, “the client may return true if edges prior to after exist, if it can do so efficiently.” The client returns nothing. It asked. The word is a slip, and it has stood in the published text. The section’s own anchor reads sec-undefined.PageInfo, a second slip in the paperwork that also still stands. ↩︎ Back to text

  2. The specification stops short of forbidding the pair. Its pagination algorithm calls passing both first and last “strongly discouraged” on the grounds that it is likely to lead to confusing queries and results. The PageInfo section then defines both flags for that case anyway, and adds that their meaning “becomes unclear” once it has. A document that discourages a thing and then specifies it in full expects to be ignored on the point. Refusing the pair is stricter than the document, and it takes one line where the document took two notes. ↩︎ Back to text

  3. The reference implementation is less shy. The array helpers in graphql-relay-js make a cursor by writing arrayconnection: in front of the array index and base64 encoding the result, so YXJyYXljb25uZWN0aW9uOjI= is index 2 wearing a label. Its README describes what offsetToCursor returns as an opaque cursor, which it is, in the sense that base64 is a lock. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.