JsonApi4j supports JSON:API pagination for all multi-resource operations — both resource reads (GET /users) and to-many relationship reads (GET /users/1/relationships/citizenships).

The framework handles the JSON:API wire format: parsing page[...] query parameters from the client and generating pagination links (self, first, prev, next, last) in the response. Your code handles the actual data slicing — the framework never touches your data source.

Pagination Strategies

JsonApi4j supports two pagination strategies: cursor-based and limit-offset. Both are exposed through the same PaginationAwareResponse return type — the factory method you choose determines which strategy is used.

Cursor-Based Pagination

Cursor-based pagination uses an opaque token (page[cursor]) to identify the position in the result set. This is the recommended strategy for most APIs — it handles dynamic data well and prevents skipped/duplicated items when the dataset changes between requests.

The cursor value is available via request.getCursor(). A null cursor means the client is requesting the first page.

Server-side cursor — use when your data source natively supports cursors (e.g., Elasticsearch scroll, DynamoDB pagination token):

@Override
public PaginationAwareResponse<UserDbEntity> readPage(JsonApiRequest request) {
    DbPage<UserDbEntity> page = userDb.readAllUsers(request.getCursor());
    return PaginationAwareResponse.cursorAware(
        page.getEntities(),
        page.getNextCursor()  // null if this is the last page
    );
}

The framework generates a first link (the URL without a cursor parameter) and a next link when the cursor is non-null. Since cursor-based pagination is forward-only, prev and last are always null.

In-memory cursor — use when your data source uses limit-offset internally but you want cursor-based pagination on the API. LimitOffsetToCursorAdapter encodes limit and offset into a Base62 cursor string:

@Override
public PaginationAwareResponse<UserDbEntity> readPage(JsonApiRequest request) {
    LimitOffsetToCursorAdapter adapter = new LimitOffsetToCursorAdapter(request.getCursor())
        .withDefaultLimit(20);
    LimitOffsetToCursorAdapter.LimitAndOffset limitAndOffset = adapter.decodeLimitAndOffset();

    List<UserDbEntity> items = userDb.readAll(limitAndOffset.getLimit(), limitAndOffset.getOffset());
    String nextCursor = adapter.nextCursor(userDb.totalCount());
    return PaginationAwareResponse.cursorAware(items, nextCursor);
}

For simpler cases where the full dataset is available in memory, use the convenience method that handles slicing and cursor generation internally:

@Override
public PaginationAwareResponse<DownstreamCountry> readMany(JsonApiRequest request) {
    List<DownstreamCountry> allItems = countriesClient.readCountries();
    return PaginationAwareResponse.inMemoryCursorAware(
        allItems,
        request.getCursor(),
        10  // page size
    );
}

Limit-Offset Pagination

Limit-offset pagination uses page[limit] and page[offset] query parameters. This is a familiar model for SQL-backed APIs. Defaults are limit=20 and offset=0.

These values are available via request.getLimit() and request.getOffset().

Server-side limit-offset — use when your data source supports LIMIT/OFFSET natively and can provide a total count:

@Override
public PaginationAwareResponse<UserDbEntity> readPage(JsonApiRequest request) {
    List<UserDbEntity> items = userDb.readAll(request.getLimit(), request.getOffset());
    long total = userDb.totalCount();
    return PaginationAwareResponse.limitOffsetAware(items, total);
}

The framework generates first, prev, next, and last links automatically based on the total count and current position. prev is null on the first page; next is null on the last page.

In-memory limit-offset — use when slicing a full dataset in memory:

return PaginationAwareResponse.inMemoryLimitOffsetAware(allItems, request.getLimit(), request.getOffset());

Non-Pageable Responses

When a response should return all items without pagination (e.g., a small lookup table), use:

return PaginationAwareResponse.fromItemsNotPageable(allCountries);

No pagination links are generated in the response.

PaginationAwareResponse Factory Methods

Factory Method Strategy Use Case
cursorAware(items, nextCursor) Cursor Data source provides a native cursor or token
inMemoryCursorAware(items, cursor, pageSize) Cursor Full dataset in memory; framework handles slicing
limitOffsetAware(items, totalItems) Limit-offset Data source supports LIMIT/OFFSET and total count
inMemoryLimitOffsetAware(items, limit, offset) Limit-offset Full dataset in memory; framework handles slicing
fromItemsNotPageable(items) None Return all items without pagination
empty() None Empty response with no items

The framework automatically generates pagination links based on the strategy. The JSON:API specification defines four pagination links: first, prev, next, and last. Links that are unavailable are set to null.

Cursor-based (second page):

"links": {
    "self": "/users?page[cursor]=DoJu",
    "first": "/users",
    "prev": null,
    "next": "/users?page[cursor]=DoJw",
    "last": null
}

Cursor-based pagination is forward-only, so prev and last are always null. The first link is always the URL without a page[cursor] parameter. The next link is null when there are no more pages.

Limit-offset (second page, 100 total items):

"links": {
    "self": "/users?page[offset]=20&page[limit]=20",
    "first": "/users?page[offset]=0&page[limit]=20",
    "prev": "/users?page[offset]=0&page[limit]=20",
    "next": "/users?page[offset]=40&page[limit]=20",
    "last": "/users?page[offset]=80&page[limit]=20"
}

All four links are available in limit-offset mode when the total count is known. prev is null on the first page; next is null on the last page.

You can customize link generation by overriding resolveTopLevelLinksForMultiResourcesDoc() on your Resource — the PaginationContext parameter provides the cursor, total items, and pagination mode.

Pagination Meta

In addition to pagination links, the framework automatically includes pagination context in the top-level meta object of multi-resource responses. This is enabled by default and provides clients with structured pagination metadata alongside the standard links.

Cursor-based — when a next page is available, the response includes the next cursor value:

"meta": {
    "pagination.nextCursor": "DoJw"
}

Limit-offset — the total number of items available on the server is included:

"meta": {
    "pagination.totalItems": 26
}

The meta object is only populated when pagination context is present — pagination.nextCursor appears only when the next cursor is non-null (i.e., there are more pages), and pagination.totalItems appears only for limit-offset responses. Non-pageable responses (fromItemsNotPageable) do not include pagination meta.

You can customize meta generation by overriding resolveTopLevelMetaForMultiResourcesDoc() on your Resource.

Relationship Pagination

To-many relationships have their own independent pagination. A user’s citizenships relationship is paginated separately from the users list:

GET /users/1/relationships/citizenships                      → first page
GET /users/1/relationships/citizenships?page[cursor]=DoJu    → second page

This is handled the same way as resource pagination — your readMany() method returns a PaginationAwareResponse, and the framework generates the appropriate pagination links.

Choosing a Strategy

Consideration Cursor Limit-Offset
Dynamic data (inserts/deletes between pages) No skipped/duplicated items Items may be skipped or duplicated
Jump to arbitrary page Not supported Supported via page[offset]
Total count required No Yes, for accurate pagination links
Pagination links generated first, next first, prev, next, last
Implementation complexity Lower with LimitOffsetToCursorAdapter Straightforward with SQL

The JSON:API specification does not mandate a specific pagination strategy. JsonApi4j defaults to cursor-based pagination in its examples and utilities, but both strategies are fully supported.