Cursor Pagination Examples
These examples demonstrate cursor-based pagination patterns for stateless queries.
Referenced by: cursor-patterns.md, patterns.md
Example 1: Basic Node Pagination
import { encodeCursor, decodeCursor } from '@/lib/cursor';
import { listNodes } from '@db/graph';
async function paginateNodes(
client: PoolClient,
cursor?: string
) {
const limit = 50;
// Decode cursor if present
const cursorData = cursor ? decodeCursor(cursor) : null;
const afterId = cursorData?.lastId;
// Query with cursor
const nodes = await listNodes(client, {
limit: limit + 1, // Fetch extra to check if more exist
after_id: afterId
});
// Check if more results exist
const hasMore = nodes.length > limit;
const results = hasMore ? nodes.slice(0, limit) : nodes;
// Create next cursor
const nextCursor = hasMore
? encodeCursor({
lastId: results[results.length - 1].id,
limit,
direction: 'forward',
timestamp: Date.now(),
version: 1
})
: null;
return {
nodes: results,
cursor: nextCursor,
hasMore
};
}
Example 2: Edge Pagination
import { encodeCursor, decodeCursor } from '@/lib/cursor';
import { listEdges } from '@db/graph';
async function paginateEdges(
client: PoolClient,
sourceId: bigint,
cursor?: string
) {
const limit = 50;
const cursorData = cursor ? decodeCursor(cursor) : null;
const edges = await listEdges(client, {
source_id: sourceId,
after_id: cursorData?.lastId,
limit: limit + 1
});
const hasMore = edges.length > limit;
const results = hasMore ? edges.slice(0, limit) : edges;
const nextCursor = hasMore
? encodeCursor({
lastId: results[results.length - 1].id,
limit,
direction: 'forward',
timestamp: Date.now(),
version: 1
})
: null;
return {
edges: results,
cursor: nextCursor,
hasMore
};
}
Example 3: REST API Endpoint with Cursors
// GET /api/nodes?cursor=...
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const cursor = searchParams.get('cursor') ?? undefined;
const result = await withClient(async (client) => {
return await paginateNodes(client, cursor);
});
return Response.json({
data: result.nodes,
pagination: {
cursor: result.cursor,
hasMore: result.hasMore
}
});
}
Example 4: GraphQL Resolver with Cursors
const resolvers = {
Query: {
nodes: async (
_parent,
{ cursor, limit },
{ client }
) => {
const result = await paginateNodes(client, cursor);
return {
edges: result.nodes.map(node => ({
node,
cursor: encodeCursor({
lastId: node.id,
limit,
direction: 'forward',
timestamp: Date.now(),
version: 1
})
})),
pageInfo: {
hasNextPage: result.hasMore,
endCursor: result.cursor
}
};
}
}
};
Example 5: Offset-Based Cursor
import { encodeCursor, decodeCursor } from '@/lib/cursor';
async function paginateWithOffset(
client: PoolClient,
cursor?: string
) {
const limit = 25;
// Decode cursor or start at 0
const cursorData = cursor ? decodeCursor(cursor) : null;
const offset = cursorData?.offset ?? 0;
// Query with offset
const result = await client.query(
'SELECT * FROM nodes ORDER BY id LIMIT $1 OFFSET $2',
[limit + 1, offset]
);
const hasMore = result.rows.length > limit;
const nodes = hasMore ? result.rows.slice(0, limit) : result.rows;
// Create next cursor
const nextCursor = hasMore
? encodeCursor({
offset: offset + limit,
limit,
direction: 'forward',
timestamp: Date.now(),
version: 1
})
: null;
return {
nodes,
cursor: nextCursor,
hasMore
};
}
Example 6: Value-Based Cursor (Custom Sort)
import { encodeCursor, decodeCursor } from '@/lib/cursor';
async function paginateByCreatedAt(
client: PoolClient,
cursor?: string
) {
const limit = 50;
const cursorData = cursor ? decodeCursor(cursor) : null;
// Build query with value-based cursor
let query = 'SELECT * FROM nodes';
const params: any[] = [];
if (cursorData?.lastValue && cursorData?.lastId) {
query += ' WHERE (created_at, id) > ($1, $2)';
params.push(cursorData.lastValue, cursorData.lastId);
}
query += ' ORDER BY created_at, id LIMIT $' + (params.length + 1);
params.push(limit + 1);
const result = await client.query(query, params);
const hasMore = result.rows.length > limit;
const nodes = hasMore ? result.rows.slice(0, limit) : result.rows;
const nextCursor = hasMore && nodes.length > 0
? encodeCursor({
lastValue: nodes[nodes.length - 1].created_at,
lastId: nodes[nodes.length - 1].id,
limit,
direction: 'forward',
timestamp: Date.now(),
version: 1
})
: null;
return {
nodes,
cursor: nextCursor,
hasMore
};
}
Example 7: Cursor Error Handling
function decodeCursor(cursor: string): CursorData | null {
try {
const decoded = JSON.parse(
Buffer.from(cursor, 'base64').toString('utf-8')
);
// Validate structure
if (!decoded.version || !decoded.timestamp) {
return null;
}
// Check age (e.g., 1 hour max)
const age = Date.now() - decoded.timestamp;
if (age > 3600000) {
console.warn('Cursor expired');
return null;
}
return decoded;
} catch (error) {
console.error('Invalid cursor:', error);
return null;
}
}
async function paginateWithFallback(
client: PoolClient,
cursor?: string
) {
try {
return await paginateNodes(client, cursor);
} catch (error) {
if (error.message === 'Invalid cursor') {
// Start from beginning
return await paginateNodes(client);
}
throw error;
}
}
Example 8: Test Cursor Pagination
import { test } from '@/test-utils-integration/config/database.context';
import { createResource } from '@db/graph';
test('pagination with cursors', async ({ newDbClient }) => {
// Create test data
const ids = await Promise.all([
createResource(newDbClient, { p_type_namespace: 'test.node' }),
createResource(newDbClient, { p_type_namespace: 'test.node' }),
createResource(newDbClient, { p_type_namespace: 'test.node' })
]);
// First page
const page1 = await paginateNodes(newDbClient);
expect(page1.nodes.length).toBeLessThanOrEqual(50);
// Second page
if (page1.cursor) {
const page2 = await paginateNodes(newDbClient, page1.cursor);
// Verify no overlap
if (page2.nodes.length > 0) {
expect(page2.nodes[0].id).toBeGreaterThan(
page1.nodes[page1.nodes.length - 1].id
);
}
}
});