Skip to content

Pagination

import 'package:gisila/gisila.dart';

Validated limit / offset / cursor values.

class PageParams {
PageParams({
int? limit,
int? offset,
String? cursor,
int defaultLimit = 25,
int maxLimit = 100,
int maxOffset = 100000,
});
final int limit;
final int offset;
final String? cursor;
}

When to use: on every list endpoint, so clients can’t ask for everything at once. Declare limit/offset/cursor as nullable query parameters on the route, then pass them in.

Parameter Default Behaviour
limit defaultLimit (25) Above maxLimit (100) it is cut down to maxLimit. Below 1 is a 400 (invalid_limit).
offset 0 Negative or above maxOffset (100000) is a 400 (invalid_offset).
cursor null Stored as-is; decode it with Cursor.decode.

One page of results, rendered as {"items": [...], "page": {...}}.

class Page<T> {
Page(
Iterable<T> items, {
PageParams? params,
int? limit,
int? offset,
int? total,
String? nextCursor,
bool? hasMore,
});
final List<T> items;
final int? limit;
final int? offset;
final int? total;
final String? nextCursor;
final bool hasMore;
Map<String, Object?> toJson();
}

When to use: as the return type of list routes, so every list in your API has the same shape.

Parameter Description
items The rows; encoded through their toJson().
params Fills limit, and offset when no cursor was used.
limit, offset Override what params provides.
total Total count, when you have it.
nextCursor Cursor for the next page.
hasMore Defaults to: true when nextCursor is set; else offset + items < total when total is known; else items.length >= limit.

toJson() produces page.limit, page.offset, page.total, page.next_cursor (each only when set) and page.has_more.

{"items": [{"id": 51}, {"id": 52}], "page": {"limit": 25, "offset": 50, "total": 240, "has_more": true}}

Example: offset pagination

@Get('/')
@Permissions({'posts:read'})
Future<Page<Post>> list(PostService posts, {int? limit, int? offset}) async {
final page = PageParams(limit: limit, offset: offset);
final rows = await posts.list(limit: page.limit, offset: page.offset);
return Page(rows, params: page, total: await posts.count());
}

Opaque cursors for keyset pagination.

abstract final class Cursor {
static String encode(Map<String, Object?> position);
static Map<String, Object?> decode(String cursor);
}

encode JSON-encodes the position (for example the last row’s sort key and id) and base64url-encodes it without padding. decode reverses it; a malformed cursor is a 400 (invalid_cursor).

When to use: large or fast-changing tables, where offsets get slow and skip or repeat rows. Cursors are not signed: decode them only into values you would accept from the client anyway, and keep authorization in the query.

Example: cursor pagination

@Get('/feed')
Future<Page<Post>> feed(PostService posts, {int? limit, String? cursor}) async {
final page = PageParams(limit: limit, cursor: cursor);
final after = cursor == null ? null : Cursor.decode(cursor);
final rows = await posts.after(id: after?['id'] as int?, limit: page.limit + 1);
final more = rows.length > page.limit;
final items = rows.take(page.limit).toList();
return Page(items,
params: page,
nextCursor: more ? Cursor.encode({'id': items.last.id}) : null);
}

See also: Build a CRUD API.