Skip to main content

Why Cursor-Based Pagination

Traditional offset pagination (page=2&limit=20) breaks down at scale. When a guest is browsing page 5 and a new dish gets added, suddenly they see duplicates or miss items entirely. For a dining app where menu availability changes in real-time, this creates a frustrating experience. EveryBite uses cursor-based pagination—the same approach used by Twitter, Facebook, and Slack. Instead of “give me page 2”, you say “give me the next 20 items after this cursor.” The cursor is an opaque marker that points to a specific position in the result set, ensuring stable pagination even as data changes.

How It Works

1

First Request

Request the first page of results:
search(pagination: { first: 20 })
Response includes dishes 1-20, plus endCursor: "abc123" and hasNextPage: true
2

Next Page

User scrolls down. Request the next page using the cursor:
search(pagination: { first: 20, after: "abc123" })
Response includes dishes 21-40, plus endCursor: "def456" and hasNextPage: true
3

Final Page

User scrolls again. Request the next page:
search(pagination: { first: 20, after: "def456" })
Response includes dishes 41-52 and hasNextPage: false — no more results.
Each response includes:
  • Results — The dishes for this page
  • endCursor — Pass this as after to get the next page
  • hasNextPage — Whether more results exist

Pagination Parameters

input PaginationArgs {
  first: Int      # Number of results to return when paginating forwards (default: 25, max: 100)
  after: String   # Cursor for next page (forward pagination)
  last: Int       # Number of results to return when paginating backwards
  before: String  # Cursor for previous page (backward pagination)
}
ParameterTypeDefaultDescription
firstInt25Number of results per page when paginating forwards (max 100)
afterStringCursor marking where to continue from when paginating forwards
lastIntNumber of results per page when paginating backwards
beforeStringCursor marking where to continue from when paginating backwards

Page Info Response

Every paginated response includes a pageInfo object:
type PageInfo {
  hasNextPage: Boolean!      # More results available after endCursor
  hasPreviousPage: Boolean!  # Results exist before startCursor
  startCursor: String        # Cursor of first item in this page
  endCursor: String          # Cursor of last item—pass as `after` for next page
}

Example: Infinite Scroll

The most common pattern—load more results as the user scrolls:
const [dishes, setDishes] = useState([]);
const [cursor, setCursor] = useState(null);
const [hasMore, setHasMore] = useState(true);

async function loadMore() {
  const { data } = await client.query({
    query: SMART_MENU_SEARCH,
    variables: {
      preferences: userPreferences,
      pagination: { first: 20, after: cursor }
    }
  });

  const { matches, pageInfo } = data.search;

  setDishes(prev => [...prev, ...matches]);
  setCursor(pageInfo.endCursor);
  setHasMore(pageInfo.hasNextPage);
}

// Trigger loadMore when user scrolls near bottom

Example: Load More Button

For explicit pagination with a “Load More” button:
query SearchWithPagination($cursor: String) {
  search(
    preferences: { diets: [VEGETARIAN] }
    pagination: { first: 20, after: $cursor }
  ) {
    matches {
      dish { id, name, nutrition { calories } }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    counts { total }
  }
}
{
  "data": {
    "search": {
      "matches": [
        { "dish": { "id": "dish_021", "name": "Veggie Wrap", "nutrition": { "calories": 380 } } },
        { "dish": { "id": "dish_022", "name": "Garden Bowl", "nutrition": { "calories": 420 } } }
      ],
      "pageInfo": {
        "hasNextPage": true,
        "endCursor": "eyJpZCI6NDAsInMiOjEuMH0="
      },
      "counts": { "total": 52 }
    }
  }
}

Pagination with Match Groups

When paginating search results, pagination applies across all match groups:
query PaginatedSearch($cursor: String) {
  search(
    preferences: { diets: [VEGETARIAN] }
    pagination: { first: 20, after: $cursor }
  ) {
    matches {
      dish { id, name }
      matchStatus
    }
    almostMatches {
      dish { id, name }
      matchReasons
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
Results are returned in match quality order: all matches first, then almostMatches, then notMatches. Pagination respects this ordering.

Cursor Stability

Cursors remain valid for the duration of a session. However:
  • Cursors are opaque — Don’t parse or construct them; treat as strings
  • Cursors are session-scoped — A cursor from one session won’t work in another
  • Cursors expire — After session timeout (30 min inactivity), start fresh

Best Practices

Request Reasonable Pages

20-50 items per page balances performance and UX. Avoid requesting 100 items if you only show 10.

Don't Store Cursors

Cursors are ephemeral. Don’t persist them to local storage or databases.

Handle Empty Pages

If hasNextPage is false and results are empty, show an appropriate empty state.

Show Total Count

Use counts.total to show “Showing 20 of 52 dishes” for better UX.

Why Not Offset Pagination?

IssueOffset PaginationCursor Pagination
New items addedDuplicates or missed itemsStable results
Items removedSkipped itemsStable results
Deep pagesSlow (must skip N items)Fast (direct lookup)
Real-time dataInconsistentConsistent
For a menu that can change in real-time (dishes sold out, specials added), cursor pagination ensures guests see a consistent, complete list.