Designing Clean Pagination, Filtering and Sorting for REST APIs
AI generated
{ }
GET
REST API · Pagination · Filtering · Sorting
Designing Clean Pagination, Filtering
and Sorting for REST APIs

List endpoints are the most complex parts of a REST API: they must support pagination, filtering and sorting consistently without bloating the API surface. The difference between a well designed and a poorly designed list endpoint comes down to three decisions: cursor or offset, which filter parameters are allowed, and how the meta object informs consumers.

17 min read Cursor · Offset · Filter · Sort · Meta object REST API design · OpenAPI 3.x

1. The fundamental decision: cursor or offset?

The first and most important design decision for any list endpoint is choosing the pagination mechanism. Offset pagination (?page=2&per_page=20) is intuitive, widely used and easy to implement. Cursor pagination (?cursor=eyJpZCI6MTAwfQ&limit=20) is more complex, but more consistent with changing data and scales better for large datasets.

The decision depends on the use case: if users or clients need to jump to arbitrary pages (for example page 47 of 200), offset is the only practical option. If the data changes frequently and users navigate sequentially through results (for example an activity feed or log stream), cursor is the more reliable choice. The problem with offset on changing data: if a new item is inserted at the front between page 1 and page 2, the entire offset shifts, items get shown twice or not at all.

A pragmatic strategy: offset for admin interfaces and reports (arbitrary page jumps matter), cursor for user feeds and timelines (consistency matters). Both models can coexist within the same API, different endpoints with different requirements can use different pagination types. This should be clearly signaled in the OpenAPI documentation.

2. Offset pagination: simple, but with limits

Offset pagination is the simplest starting point and is fully sufficient for many use cases. The basic form: ?page=2&per_page=20 or alternatively ?offset=40&limit=20. Both variants are common, but only one should be used consistently within a given API. The page/per_page variant is more user friendly and closer to human intuition ("page 3"), the offset/limit variant is more precise and maps more directly to the SQL layer.

Important design decisions for offset pagination: the maximum for per_page must be limited (typically 100 or 200) to prevent resource-exhaustion attacks. The default should be sensible (20 or 25 is common). The total count in the meta object is useful with offset so clients can calculate the total number of pages, but it is also expensive because a COUNT query on large tables is slow. For very large datasets, an approximate total (totalApproximate) or no total at all can be a better option.


# Offset pagination, OpenAPI parameter definitions
components:
  parameters:
    PageParam:
      name: page
      in: query
      description: Page number (1-indexed)
      schema:
        type: integer
        minimum: 1
        default: 1

    PerPageParam:
      name: per_page
      in: query
      description: Items per page. Maximum 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

  schemas:
    OffsetPaginationMeta:
      type: object
      required: [total, page, perPage, totalPages]
      properties:
        total:
          type: integer
          minimum: 0
          description: Total number of items matching the current filters
          example: 842
        page:
          type: integer
          minimum: 1
          description: Current page number
          example: 3
        perPage:
          type: integer
          minimum: 1
          maximum: 100
          description: Items per page as requested
          example: 20
        totalPages:
          type: integer
          minimum: 0
          description: Total number of pages (ceil(total / perPage))
          example: 43
        links:
          type: object
          description: Navigation links for common page transitions
          properties:
            self: { type: string, format: uri }
            first: { type: string, format: uri }
            prev: { type: string, format: uri, nullable: true }
            next: { type: string, format: uri, nullable: true }
            last: { type: string, format: uri }

3. Cursor pagination: consistent with changing data

Cursor pagination uses an opaque pointer (cursor) to a position in the dataset. The cursor is typically a Base64-encoded JSON object that holds the value of the sort field of the last item returned, for example {"id": 100, "createdAt": "2026-05-10T12:00:00Z"} as the cursor for a dataset sorted by createdAt DESC, id DESC. The server can safely decode this cursor and constrain the query with a WHERE (createdAt, id) < (cursorCreatedAt, cursorId) condition.

An important API design principle: cursors must be opaque to clients. Clients must not interpret or construct the cursor, it is generated by the server and sent back unchanged in the next request. This lets the server change the cursor implementation without breaking the API surface. The cursor should be time-limited (for example 24 hours) to prevent clients from using very old cursors indefinitely.


# Cursor pagination, OpenAPI definitions
components:
  parameters:
    CursorParam:
      name: cursor
      in: query
      description: |
        Opaque pagination cursor from the previous response's meta.cursors.next.
        Do NOT construct or interpret this value, treat as a black box.
        Cursors expire after 24 hours.
      schema:
        type: string
        example: eyJpZCI6MTAwLCJjcmVhdGVkQXQiOiIyMDI2LTA1LTEwIn0

    LimitParam:
      name: limit
      in: query
      description: Number of items to return. Maximum 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

  schemas:
    CursorPaginationMeta:
      type: object
      required: [limit, hasMore]
      properties:
        limit:
          type: integer
          description: Items per page as requested
          example: 20
        hasMore:
          type: boolean
          description: Whether more items exist after the current page
          example: true
        cursors:
          type: object
          properties:
            next:
              type: string
              nullable: true
              description: Cursor for the next page. Null if this is the last page.
              example: eyJpZCI6MTIwfQ
            prev:
              type: string
              nullable: true
              description: Cursor for the previous page. Null if this is the first page.
              example: eyJpZCI6ODl9

# Example usage in a list endpoint
paths:
  /orders:
    get:
      summary: List orders (cursor-paginated)
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: Order list
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Order'
                  meta:
                    $ref: '#/components/schemas/CursorPaginationMeta'

4. The meta object: what consumers actually need

The meta object in a list response is the interface between the API and the pagination logic in the client. What consumers actually need varies by pagination type and use case. For offset pagination: total count, current page, total pages and direct navigation links. For cursor pagination: a hasMore flag, cursors for the next and previous page.

A common mistake: the meta object is designed so that the client still has to compute things. If total and perPage are returned, the client has to compute Math.ceil(total/perPage) to know the page count. Better: provide totalPages directly. If cursor is returned but no hasMore flag, the client has to test whether cursor is null. Better: explicitly state hasMore: false. The meta object should be designed so client code stays minimal.

5. Filter parameters: syntax, types and limits

Filter parameters are one of the most common sources of API design inconsistency. Three different teams build three different filter syntaxes: ?status=active, ?filter[status]=active and ?filter=status:active. Consumers have to learn three different syntaxes, documentation becomes more complex, and generated client code has to support all three variants.

The recommendation: simple equality filters as direct query parameters (?status=active&category=electronics). Multiple values via comma-separated lists or a repeated parameter (?status=active,pending or ?status=active&status=pending). Range filters via dedicated parameters (?price_min=10&price_max=100 or ?created_after=2026-01-01&created_before=2026-12-31). For complex filter requirements (AND/OR combinations, negation) a JSON-based filter parameter is a good fit, but only if the API genuinely needs to allow complex queries.


# Filter parameters, OpenAPI definitions for list endpoint
paths:
  /products:
    get:
      summary: List products with filtering, sorting and pagination
      parameters:
        # Simple equality filter
        - name: status
          in: query
          description: Filter by product status. Multiple values allowed (comma-separated).
          schema:
            type: string
            enum: [active, inactive, draft, archived]
          example: active
        - name: category_id
          in: query
          description: Filter by category UUID. Matches exact category, not children.
          schema:
            type: string
            format: uuid
        # Range filter
        - name: price_min
          in: query
          description: Minimum price (inclusive), in EUR cents.
          schema:
            type: integer
            minimum: 0
        - name: price_max
          in: query
          description: Maximum price (inclusive), in EUR cents.
          schema:
            type: integer
            minimum: 0
        # Date range filter
        - name: created_after
          in: query
          description: Only products created after this date (ISO 8601 date).
          schema:
            type: string
            format: date
            example: "2026-01-01"
        - name: created_before
          in: query
          schema:
            type: string
            format: date
            example: "2026-12-31"
        # Full-text search
        - name: q
          in: query
          description: Full-text search across name and description. Min 2 chars.
          schema:
            type: string
            minLength: 2
            maxLength: 200
        # Sorting
        - name: sort
          in: query
          description: >
            Sort field and direction. Prefix with - for descending.
            Supported fields: name, price, createdAt, updatedAt.
          schema:
            type: string
            enum: [name, -name, price, -price, createdAt, -createdAt, updatedAt, -updatedAt]
            default: -createdAt
        # Pagination
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/PerPageParam'

6. Sorting: keep it simply consistent

Sort parameters are simpler to design than filter parameters, but inconsistencies are still common. The most common pattern: ?sort=field for ascending, ?sort=-field for descending (the minus-sign prefix is inspired by many JSON:API implementations). Alternatively: ?sort_by=name&sort_order=asc as separate parameters.

Important design decisions: which fields are sortable? Not every field that is returned is meaningful or efficient to sort by. Defining the sortable fields in the OpenAPI documentation as an enum is better than allowing a free-form string, it prevents malformed sort parameters that lead to server errors or unexpected behavior. Multi-field sorting (for example ?sort=-createdAt,name) is optional and should only be added if consumers genuinely need it.

7. Fully documenting list endpoints in OpenAPI

The complete OpenAPI documentation of a list endpoint takes more effort than that of a single resource endpoint, because all parameter combinations and their effects on the response must be documented. Important: parameter combinations that are not supported (for example cursor pagination together with a page parameter) should be explicitly documented, either in the description field or via a 400 response with a description.

Reusable components significantly reduce repetition: pagination parameters as $ref references, meta schemas as reusable components, shared filter parameters (date range, search) as parameter components. This makes OpenAPI documents more maintainable and reduces the chance that different list endpoints use different conventions for the same concept.

Criterion Offset pagination Cursor pagination Recommendation
Implementation effort Low Medium-high Offset for simple cases
Consistency under data changes Low (duplicates/gaps) High Cursor for changing data
Arbitrary page jumps Yes (page 47 directly) No Offset for admin UIs
Performance on large datasets Low (OFFSET is expensive) High (index scan) Cursor from ~100k rows
Total count available Yes (COUNT query) No (by design) Offset when total is needed

8. Performance considerations for pagination and filtering

Pagination and filtering have a direct impact on database performance, which must be taken into account in API design. Offset pagination with large offsets (LIMIT 20 OFFSET 10000) is expensive: the database has to read and discard 10,000 rows. Cursor pagination avoids this via a WHERE condition on indexed columns. On large datasets (from around 100,000 rows), the performance difference is measurable.

Filter parameters that operate on unindexed fields can trigger full table scans. API design should be aware of the database reality: only fields that are indexed or can be queried efficiently should be exposed as filter parameters. This does not mean the API has to expose every database constraint, but it does mean the API designer coordinates with the database team on which filter combinations should be supported.

10. Summary

Designing clean pagination, filtering and sorting for REST APIs requires three clear decisions: cursor or offset (depending on consistency requirements and dataset size), which filter parameters and syntaxes are used consistently across all list endpoints, and how the meta object supplies consumers with all the information they need without requiring them to compute anything. Making these decisions early and holding to them consistently is more important than the specific syntax choice.

OpenAPI documentation for list endpoints pays off in particular: reusable parameters and schemas for pagination and filtering reduce repetition and enforce consistency. Clients generated from OpenAPI get the pagination logic for free. Contract tests with Postman/Newman verify that the meta object is populated correctly and that cursors work. The effort for careful list endpoint design is a one-time cost, poor design haunts an API for its entire lifetime.

Pagination, filtering and sorting, the essentials at a glance

Cursor vs. offset

Cursor for changing data and large datasets. Offset when page jumps or total count are needed. Both can coexist in the same API.

Meta object

Design it so the client doesn't have to compute anything: totalPages instead of total+perPage, hasMore instead of a cursor-null check, navigation links directly in the meta.

Filter consistency

Simple equality filters as direct query parameters. Range filters as _min/_max pairs. Expose only indexed fields as filters.

OpenAPI reuse

Pagination parameters and meta schemas as reusable components/$ref. Define sortable fields as an enum, it prevents malformed sort parameters.

11. FAQ: Pagination, filtering and sorting in REST APIs

1Cursor vs. offset: main difference?
Offset allows page jumps, but is inconsistent under changing data. Cursor is consistent, but does not allow arbitrary jumps. The choice depends on the use case.
2When to use cursor pagination?
For frequently changing data (feeds, logs) and for datasets from around 100k rows, where OFFSET queries become expensive.
3How opaque does a cursor need to be?
Fully opaque. Clients must not interpret or construct it. Base64-encoded JSON is common. The internal structure can be changed at any time.
4Always include total in the meta object?
Usually yes with offset. But COUNT queries can be expensive on large tables. Alternatives: an approximate total or omit it and use hasMore.
5Filter syntax for multiple values?
Comma-separated list (?status=active,pending) or a repeated parameter (?status=active&status=pending). Important: consistency across all endpoints.
6Offer every field as a filter?
No. Only indexed fields. Exposing unindexed fields as filters risks full table scans. The API surface should reflect the database's strengths.
7Document incompatible parameters in OpenAPI?
Explain it in the description field of the parameters or operation. Document a 400 response with a description. OpenAPI has no native concept for parameter incompatibility.
8Minus-sign prefix for sorting?
?sort=-createdAt = sorted in descending order. Inspired by JSON:API, widely used. Alternative: ?sort_by=createdAt&sort_order=desc as separate parameters.
9Offset and cursor in the same API?
Yes. Different endpoints can have different types. Signal this clearly in OpenAPI so clients know which type to expect.
10Prevent high per_page values?
A maximum constraint in the OpenAPI schema for per_page. The server validates and returns 422. Typical maximum: 100 for lists, 200 for export endpoints.