---
name: graphql-cursor-connections
source: https://app.decimal.ai/s/graphql-cursor-connections@1/SKILL.md
source_sha256: 51f70730d09d
---

# GraphQL Cursor Connections

## Contract

When a GraphQL field exposes a list that clients page through, model it with the Cursor
Connections structure: a `{Type}Connection` return type wrapping an `edges` list (each edge
carrying `node` + `cursor`) and a `pageInfo` object, paged by `first`/`after` arguments with
opaque string cursors. Apply this to any field that returns a browsable, scrollable, or
"load-more" collection — never a bare list with offset arguments.

## Rules

1. **Return a connection, not a list.** A pageable field returns a dedicated object type whose
   name is the entity plus the `Connection` suffix (`Article` -> `ArticleConnection`). Never
   return `[Article!]!` directly for a field the caller pages.
2. **The connection has two core fields.** `edges: [{Type}Edge!]!` and `pageInfo: PageInfo!`.
   A `totalCount: Int` may be added alongside them, but it never replaces either one.
3. **Edges wrap the record.** The edge type is `{Type}Edge` and holds two fields: `node: {Type}!`
   (the record itself) and `cursor: String!` (the position marker). The `cursor` lives on the
   EDGE, never on the record/node type.
4. **`PageInfo` carries the paging flags.** At minimum `hasNextPage: Boolean!` and
   `endCursor: String` for forward paging. Add `hasPreviousPage: Boolean!` and
   `startCursor: String` when backward paging is offered. These stay inside the `PageInfo`
   object; they are never flattened onto the connection root.
5. **Arguments are `first`/`after`.** Forward paging takes `first: Int` (how many) and
   `after: String` (the cursor to resume past). Backward paging takes `last: Int` and
   `before: String`. Never `limit`/`offset`, `page`/`pageSize`, `skip`/`take`, or `perPage`.
6. **Cursors are opaque strings.** A cursor is a `String` — an opaque token (commonly base64),
   not a raw integer offset, page number, or exposed database id. Callers treat it as a black box.

## Worked examples

BEFORE — the base's reflexive REST-style default (offset list):

```graphql
type Query {
  articles(limit: Int, offset: Int): [Article!]!
}
```

AFTER — the Cursor Connections form:

```graphql
type Query {
  articles(first: Int, after: String): ArticleConnection!
}

type ArticleConnection {
  edges: [ArticleEdge!]!
  pageInfo: PageInfo!
}

type ArticleEdge {
  node: Article!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  endCursor: String
}
```

BEFORE — an ad-hoc wrapper with the wrong field names:

```graphql
type EmployeePage {
  items: [Employee!]!
  nextCursor: String
  hasMore: Boolean!
  total: Int
}
```

AFTER — rename to the spec fields; `items` becomes `edges`, and the flat flags move into
`pageInfo`:

```graphql
type EmployeeConnection {
  edges: [EmployeeEdge!]!
  pageInfo: PageInfo!
  totalCount: Int
}

type EmployeeEdge {
  node: Employee!
  cursor: String!
}
```

BEFORE — cursor placed on the record type:

```graphql
type Book {
  id: ID!
  title: String!
  cursor: String!
}
```

AFTER — the position marker belongs to the edge, not the record:

```graphql
type BookEdge {
  node: Book!
  cursor: String!
}
```

## Edge cases & exceptions

- **Forward-only feed.** Still return a full connection; you may omit `hasPreviousPage` and
  `startCursor`, but keep `hasNextPage` and `endCursor`.
- **Empty result.** Return a connection with `edges: []` and `pageInfo.hasNextPage: false` — not
  `null` and not an empty bare list.
- **Need a running total.** Add `totalCount: Int` to the connection ALONGSIDE `edges`/`pageInfo`;
  do not swap the connection for `{ items, total }`.
- **Filtering and sorting.** Filter inputs and an `orderBy` argument sit next to `first`/`after`
  on the same field and do not change the connection shape.
- **Convenience shortcut.** A flat `nodes: [{Type}!]!` helper is acceptable only IN ADDITION to
  `edges`, never as a replacement — clients still need edge cursors to page.

## Do / Don't

- DO name the wrapper `{Type}Connection` and the edge `{Type}Edge`.
- DON'T return `[{Type}!]!` directly for a field the caller pages.
- DO define `cursor` on the edge.
- DON'T put `cursor` on the record/node type.
- DO page with `first`/`after` (and `last`/`before` for backward paging).
- DON'T use `limit`/`offset`, `page`/`pageSize`, `skip`/`take`, or `perPage`.
- DO expose `hasNextPage` and `endCursor` inside a `pageInfo` object.
- DON'T flatten them onto the connection as `hasMore`/`nextCursor`.
- DO keep the cursor an opaque `String`.
- DON'T expose an integer offset, page index, or database id as the cursor.

## Common mistakes

- Returning `[{Type}!]!` with `limit`/`offset` arguments — the reflexive HTTP/REST carry-over.
- Naming fields `items`, `results`, `hasMore`, `nextCursor`, or `totalPages` instead of the spec
  field names.
- Hoisting `hasNextPage`/`endCursor` to the connection root instead of nesting them in `PageInfo`.
- Attaching `cursor` to the record type rather than to the edge.
- Using an integer or offset as the cursor, which leaks position and breaks under insertions.
- Dropping the `Connection`/`Edge` type-name suffixes.

## Quick checklist

- [ ] Field returns `{Type}Connection!`, not a bare list.
- [ ] Connection has `edges: [{Type}Edge!]!` and `pageInfo: PageInfo!`.
- [ ] Edge has `node` plus `cursor: String!` (cursor on the edge).
- [ ] `PageInfo` has `hasNextPage` and `endCursor`.
- [ ] Arguments are `first`/`after` (and `last`/`before`).
- [ ] Cursor is an opaque string, not an offset or id.
