# 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.

Source: https://voxgig.com/howto/implement-relay-connections-in-a-schema

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-09-14
- Published: 2026-09-14
- Updated: 2026-09-24

## 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](https://relay.dev/graphql/connections.htm), and the
argument rules in its
[pagination algorithm section](https://relay.dev/graphql/connections.htm#sec-Pagination-algorithm)
are the part most implementations get partly wrong.[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A simple limit and offset list field](https://graphql.org/learn/pagination/) | An internal schema with small, stable lists and no Relay client | Rows that shift under a paging client, and no convention for a tool to rely on | A Relay or Apollo client expects connections |
| [Connection fields you write](https://relay.dev/graphql/connections.htm) | You want the algorithm visible and the cursors under your control | The algorithm to implement and to test, including the flags nobody checks | A schema builder in use already generates them |
| [Pothos relay plugin](https://pothos-graphql.dev/docs/plugins/relay) | A code-first schema in TypeScript, where types come from the builder | A plugin and its conventions, in return for the boilerplate | The schema is defined as SDL rather than in code |
| [graphql-relay-js helpers](https://github.com/graphql/graphql-relay-js) | You want the reference implementation of the algorithm and nothing else | Array helpers that assume the whole list is in memory | Rows 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.

```js title="connection.mjs"
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.

```js title="connection.mjs"
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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test connection.test.mjs
```

```text output
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.

## Related how-tos

- [Choose a pagination style for a list endpoint](/howto/choose-a-pagination-style-for-a-list-endpoint)

- [Encode and sign opaque pagination cursors](/howto/encode-and-sign-opaque-pagination-cursors)

## Last verified

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

[^1]: The [`PageInfo` section](https://relay.dev/graphql/connections.htm#sec-undefined.PageInfo.Fields)
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.

[^2]: The specification stops short of forbidding the pair. Its
[pagination algorithm](https://relay.dev/graphql/connections.htm#sec-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.

[^3]: The reference implementation is less shy. The
[array helpers in graphql-relay-js](https://raw.githubusercontent.com/graphql/graphql-relay-js/main/src/connection/arrayConnection.ts)
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](https://raw.githubusercontent.com/graphql/graphql-relay-js/main/README.md) describes what
`offsetToCursor` returns as an opaque cursor, which it is, in the sense that base64 is a lock.