Cursor-Based Pagination

Efficient, consistent pagination using opaque cursor tokens. Ideal for feeds, infinite scroll, and real-time data.

Query Parameters

ParameterDefaultDescription
cursorEncoded cursor token (empty for first page)
size10Items per page (max 1000)
sortSort field: field,direction (repeatable)

Example: ?cursor=eyJ2IjoiNDIiLCJkIjoibmV4dCJ9&size=25

Parsing Requests

From Query Parameters

func listPosts(w http.ResponseWriter, r *http.Request) {
    req := pageable.CursorRequestFromQuery(r.URL.Query()).
        SortableFields("id", "created_at").
        WithDefaultSort(pageable.Sort{Field: "created_at", Direction: pageable.DESC})
}

Manual Construction

req := pageable.NewCursorRequest("", 25, nil) // first page, 25 items

Decoding Cursors

cursorData, err := req.DecodedCursor()
if err != nil {
    http.Error(w, "invalid cursor", http.StatusBadRequest)
    return
}

// cursorData.Value     — primary cursor value (e.g., an ID)
// cursorData.Direction — "next" or "prev"
// cursorData.Extra     — additional fields for compound cursors

For the first page (no cursor), DecodedCursor() returns an empty CursorData with no error.

Database Helpers

req.Size        // requested page size (e.g., 25)
req.Limit()     // Size + 1 (e.g., 26) — fetch one extra to detect hasNext
req.OrderBy()   // "created_at desc"
req.HasCursor() // true if a cursor was provided

The Limit() method returns Size + 1 because cursor pagination detects whether more items exist by fetching one extra row, avoiding a separate COUNT query.

Cursor Direction

Direction is encoded inside the cursor token itself via CursorData.Direction:

// Encode a "next page" cursor
nextCursor, _ := pageable.EncodeCursor(pageable.CursorData{
    Value:     fmt.Sprintf("%d", lastItem.ID),
    Direction: pageable.Next,
})

// Encode a "previous page" cursor
prevCursor, _ := pageable.EncodeCursor(pageable.CursorData{
    Value:     fmt.Sprintf("%d", firstItem.ID),
    Direction: pageable.Prev,
})

When decoding, check the direction to adjust your query:

cursorData, _ := req.DecodedCursor()

switch cursorData.Direction {
case pageable.Next:
    // WHERE id > cursorData.Value ORDER BY id ASC
case pageable.Prev:
    // WHERE id < cursorData.Value ORDER BY id DESC
default:
    // First page — no cursor filter
}

Building Responses

posts := queryPosts(cursorData, req.Limit(), req.OrderBy())

hasNext := len(posts) > req.Size
if hasNext {
    posts = posts[:req.Size]
}

// Build next and prev cursors
var nextCursor, prevCursor string
if len(posts) > 0 {
    if hasNext {
        last := posts[len(posts)-1]
        nextCursor, _ = pageable.EncodeCursor(pageable.CursorData{
            Value:     fmt.Sprintf("%d", last.ID),
            Direction: pageable.Next,
        })
    }
    if req.HasCursor() {
        first := posts[0]
        prevCursor, _ = pageable.EncodeCursor(pageable.CursorData{
            Value:     fmt.Sprintf("%d", first.ID),
            Direction: pageable.Prev,
        })
    }
}

page := pageable.NewCursorPage(posts, nextCursor, prevCursor, hasNext, req.HasCursor(), req.Size)

Response Type

type CursorPage[T any] struct {
    Items    []T                `json:"items"`
    Metadata CursorPageMetadata `json:"metadata"`
}

type CursorPageMetadata struct {
    NextCursor string `json:"nextCursor"`
    PrevCursor string `json:"prevCursor"`
    HasNext    bool   `json:"hasNext"`
    HasPrev    bool   `json:"hasPrev"`
    Size       int    `json:"size"`
}

JSON Output

{
  "items": [
    {"id": 42, "title": "Hello"},
    {"id": 43, "title": "World"}
  ],
  "metadata": {
    "nextCursor": "eyJ2IjoiNDMiLCJkIjoibmV4dCJ9",
    "prevCursor": "eyJ2IjoiNDIiLCJkIjoicHJldiJ9",
    "hasNext": true,
    "hasPrev": true,
    "size": 25
  }
}

Compound Cursors

For stable ordering across non-unique fields (e.g., created_at + id), use the Extra field:

// Encode
cursor, _ := pageable.EncodeCursor(pageable.CursorData{
    Value:     fmt.Sprintf("%d", lastItem.ID),
    Direction: pageable.Next,
    Extra:     map[string]string{"created_at": "2024-01-15T10:30:00Z"},
})

// Decode via CursorRequest
req := pageable.NewCursorRequest(cursor, 25, nil)
data, _ := req.DecodedCursor()
// data.Value == "42"
// data.Direction == "next"
// data.Extra["created_at"] == "2024-01-15T10:30:00Z"

Use compound cursors when your sort key is not unique:

// WHERE (created_at, id) > ($1, $2) ORDER BY created_at, id
query := `SELECT * FROM posts
    WHERE (created_at, id) > ($1, $2)
    ORDER BY created_at ASC, id ASC
    LIMIT $3`
rows, err := db.Query(query, data.Extra["created_at"], data.Value, req.Limit())

Full Example

package main

import (
    "encoding/json"
    "fmt"
    "net/http"

    "github.com/ishinvin/pageable"
)

type Post struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
}

func listPosts(w http.ResponseWriter, r *http.Request) {
    req := pageable.CursorRequestFromQuery(r.URL.Query()).
        SortableFields("id", "created_at").
        WithDefaultSort(pageable.Sort{Field: "id", Direction: pageable.ASC})

    var posts []Post
    if req.HasCursor() {
        cursorData, err := req.DecodedCursor()
        if err != nil {
            http.Error(w, "invalid cursor", http.StatusBadRequest)
            return
        }
        switch cursorData.Direction {
        case pageable.Prev:
            posts = queryPostsBefore(cursorData.Value, req.Limit(), req.OrderBy())
        default:
            posts = queryPostsAfter(cursorData.Value, req.Limit(), req.OrderBy())
        }
    } else {
        posts = queryPosts(req.Limit(), req.OrderBy())
    }

    hasNext := len(posts) > req.Size
    if hasNext {
        posts = posts[:req.Size]
    }

    var nextCursor, prevCursor string
    if len(posts) > 0 {
        if hasNext {
            last := posts[len(posts)-1]
            nextCursor, _ = pageable.EncodeCursor(pageable.CursorData{
                Value:     fmt.Sprintf("%d", last.ID),
                Direction: pageable.Next,
            })
        }
        if req.HasCursor() {
            first := posts[0]
            prevCursor, _ = pageable.EncodeCursor(pageable.CursorData{
                Value:     fmt.Sprintf("%d", first.ID),
                Direction: pageable.Prev,
            })
        }
    }

    page := pageable.NewCursorPage(posts, nextCursor, prevCursor, hasNext, req.HasCursor(), req.Size)

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(page)
}