backend recipe
GraphQL Cursor Paging for a Data Grid
Last updated 29 September 2026
A GraphQL endpoint answers exactly the operations its own schema defines, so there is no
universal filter or sort argument to translate to the way SQL has one. graphqlAdapter
carries the transport, the pagination and the honest capability declaration around that; you
supply the two hooks only your own schema can write, or use the built-in default for the two
shapes worth having out of the box: an offset list with a totalCount, and a
Relay-style cursor connection.
The code
pagination: 'cursor' is the whole configuration for a Relay connection with no custom
query at all:
import { createGrid, createPushdownSource, graphqlAdapter } from '@toclocoinc/lattice-grid';
import * as compute from '@toclocoinc/lattice-grid';
const adapter = graphqlAdapter({
url: 'https://api.example.com/graphql',
field: 'orders', // the connection field name in your schema
fields: ['id', 'customer', 'amount', 'status'], // the node selection
pagination: 'cursor', // Relay-style first/after, pageInfo, edges
});
const grid = createGrid(el, {
rowKey: 'id',
columns,
source: createPushdownSource({ adapter, compute, pageSize: 100 }),
});
// The default query pushes only the window and the total; a filter or a
// sort typed into the grid is finished over that window in the browser
// unless you also supply operators and a buildQuery that emits them.
That default is not a black box. This is exactly what the shipped adapter builds and reads when you supply no hooks of your own, quoted from its source:
// The default cursor query graphqlAdapter builds when you supply no
// buildQuery of your own - quoted from the shipped adapter:
buildQuery(query) {
const range = query && query.range;
const first = range ? Number(range.end) - Number(range.start) : pageSize;
return {
query: `query LatticeQuery($first: Int, $after: String) {
orders(first: $first, after: $after) {
totalCount
pageInfo { hasNextPage endCursor }
edges { cursor node { id customer amount status } }
}
}`,
variables: { first, after: null },
};
}
// The matching default response mapping - a Relay edges/pageInfo shape
// straight into { rows, total, pageInfo }:
parseResponse(data) {
const root = data.orders || {};
const total = typeof root.totalCount === 'number' ? root.totalCount : undefined;
const edges = Array.isArray(root.edges) ? root.edges : [];
const rows = edges.map((edge) => (edge && edge.node !== undefined ? edge.node : edge));
const info = root.pageInfo || {};
return {
rows,
...(total === undefined ? {} : { total }),
pageInfo: { hasNextPage: !!info.hasNextPage, endCursor: info.endCursor ?? null },
};
}
A schema whose connection genuinely can filter or sort is reached by supplying both
operators (or a capabilities override) and a buildQuery
that actually emits the matching GraphQL argument:
const adapter = graphqlAdapter({
url: 'https://api.example.com/graphql',
operators: ['eq', 'contains'], // what your buildQuery actually emits
capabilities: { filter: 'tree', sort: 'multi' },
buildQuery(query) {
// query.filters, query.sort and query.range are the pushed half of the
// plan; only emit a filter/sort argument your schema really accepts.
return {
query: `query Orders($first: Int, $after: String, $status: String) {
orders(first: $first, after: $after, status: $status) {
totalCount
pageInfo { hasNextPage endCursor }
edges { cursor node { id customer amount status } }
}
}`,
variables: {
first: query.range ? query.range.end - query.range.start : 100,
after: null,
status: query.filters && query.filters.value,
},
};
},
});
What is pushed, what stays in the browser
By default only the window and the total are pushed: range and total
are the only two capabilities the default query can honestly claim, because it cannot know your
schema's filter or sort arguments. A filter typed into the grid, or a column header clicked to
sort, is finished over the rows already fetched in the browser rather than sent to the server at
all, until a buildQuery is supplied that genuinely expresses it. A cursor connection
is also forward-only: reaching row ten thousand costs paging forward to it one page at a time,
bounded so a connection matching far more than expected fails loudly rather than paging without
end; an offset connection jumps straight to any window instead.
The warnings a host sees
Two, both real and both named for the field that triggered them:
https://api.example.com/graphql was asked for totalCount and answered without one, so the grid has no total for this query and will scroll open-ended rather than present the page size as the whole. The schema may not expose totalCount on this connection; passcount: falseto stop asking, or aparseResponsethat reads whatever the schema does call it.
the graphql adapter was built withoutfieldsorselection, so the default query selects onlyid. Name the fields your grid shows, or pass a custombuildQuery.
Ports wanted
There is no separate downloadable server for this guide: a GraphQL endpoint's shape comes from
your own schema, not from a store this repository could ship a reference copy of. Point the
adapter above at any GraphQL API that answers a Relay connection or an offset/limit list with a
totalCount, and the default configuration runs unchanged. For the equivalent shape
over your own REST or SQL endpoint instead, with a runnable reference server included, see the
backend
recipes repository, whose "Ports wanted" section is open to PHP, .NET and Java contributions
on the same terms.
See the pushdown developer guide for the full adapter surface shared by every source, or SQL through your own endpoint for a runnable reference server over Postgres.