Cursor-Based Pagination
Efficient, consistent pagination using opaque cursor tokens. Ideal for feeds, infinite scroll, and real-time data.
Query Parameters
| Parameter | Default | Description |
|---|---|---|
cursor | — | Encoded cursor token (empty for first page) |
size | 10 | Items per page (max 1000) |
sort | — | Sort 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)
}