Documentation Index Fetch the complete documentation index at: https://docs.go-mizu.dev/llms.txt
Use this file to discover all available pages before exploring further.
The mobile package provides built-in pagination helpers that work great with mobile clients, supporting both traditional page-based and cursor-based pagination.
Quick Start
app . Get ( "/api/users" , func ( c * mizu . Ctx ) error {
page := mobile . ParsePageRequest ( c )
users , total := db . ListUsers ( page . Offset (), page . Limit ())
return c . JSON ( 200 , mobile . NewPage ( users , page , total ))
})
Request: GET /api/users?page=2&per_page=20
Response:
{
"data" : [ ... ],
"page" : 2 ,
"per_page" : 20 ,
"total" : 150 ,
"total_pages" : 8
}
app . Get ( "/api/feed" , func ( c * mizu . Ctx ) error {
page := mobile . ParsePageRequest ( c )
items , nextCursor := db . ListAfter ( page . CursorValue (), page . Limit ())
hasMore := len ( items ) == page . Limit ()
return c . JSON ( 200 , mobile . NewCursorPage ( items , nextCursor , "" , hasMore ))
})
Request: GET /api/feed?cursor=eyJpZCI6MTAwfQ&limit=20
Response:
{
"data" : [ ... ],
"next_cursor" : "eyJpZCI6MTIwfQ" ,
"has_more" : true
}
Page Request
The PageRequest type holds pagination parameters:
type PageRequest struct {
Page int // 1-indexed page number
PerPage int // Items per page
Cursor string // Cursor for cursor-based pagination
Limit int // Limit (same as PerPage, for cursor-based)
}
Parsing Page Requests
page := mobile . ParsePageRequest ( c )
// Query params parsed:
// ?page=2 → page.Page = 2
// ?per_page=20 → page.PerPage = 20
// ?limit=20 → page.Limit = 20
// ?cursor=abc123 → page.Cursor = "abc123"
Default Values
Page: 1
PerPage: 20
Limit: 20
Maximum: 100 (capped automatically)
Page Request Methods
page := mobile . ParsePageRequest ( c )
// Get offset for SQL OFFSET clause
page . Offset () // (page.Page - 1) * page.PerPage
// Get limit for SQL LIMIT clause
page . Limit () // page.PerPage (or page.Limit)
// Check if cursor-based
page . IsCursor () // true if Cursor is set
// Get cursor value
page . CursorValue () // decoded cursor string
Traditional pagination with page numbers and totals.
type PageResponse [ T any ] struct {
Data [] T `json:"data"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int `json:"total"`
TotalPages int `json:"total_pages"`
}
Creating Page Responses
func listUsers ( c * mizu . Ctx ) error {
page := mobile . ParsePageRequest ( c )
// Query database
users := db . Query ( "SELECT * FROM users LIMIT ? OFFSET ?" ,
page . Limit (), page . Offset ())
total := db . QueryRow ( "SELECT COUNT(*) FROM users" )
return c . JSON ( 200 , mobile . NewPage ( users , page , total ))
}
With Filtering
func listUsers ( c * mizu . Ctx ) error {
page := mobile . ParsePageRequest ( c )
role := c . Query ( "role" )
var users [] User
var total int
query := db . Model ( & User {})
if role != "" {
query = query . Where ( "role = ?" , role )
}
query . Count ( & total )
query . Offset ( page . Offset ()). Limit ( page . Limit ()). Find ( & users )
return c . JSON ( 200 , mobile . NewPage ( users , page , total ))
}
With Sorting
func listUsers ( c * mizu . Ctx ) error {
page := mobile . ParsePageRequest ( c )
sortBy := c . Query ( "sort" , "created_at" )
order := c . Query ( "order" , "desc" )
users , total := db . ListUsers (
page . Offset (),
page . Limit (),
sortBy ,
order ,
)
response := mobile . NewPage ( users , page , total )
// Add sorting info to response
return c . JSON ( 200 , map [ string ] any {
"data" : response . Data ,
"page" : response . Page ,
"per_page" : response . PerPage ,
"total" : response . Total ,
"total_pages" : response . TotalPages ,
"sort" : sortBy ,
"order" : order ,
})
}
Efficient pagination for infinite scroll and real-time feeds.
type CursorResponse [ T any ] struct {
Data [] T `json:"data"`
NextCursor string `json:"next_cursor,omitempty"`
PrevCursor string `json:"prev_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
Creating Cursor Responses
func listFeed ( c * mizu . Ctx ) error {
page := mobile . ParsePageRequest ( c )
// Parse cursor (e.g., base64 encoded ID)
var afterID int64
if page . IsCursor () {
afterID , _ = decodeCursor ( page . CursorValue ())
}
// Query items after cursor
items := db . Query ( `
SELECT * FROM posts
WHERE id > ?
ORDER BY id
LIMIT ?
` , afterID , page . Limit ())
// Generate next cursor from last item
var nextCursor string
if len ( items ) > 0 {
nextCursor = encodeCursor ( items [ len ( items ) - 1 ]. ID )
}
hasMore := len ( items ) == page . Limit ()
return c . JSON ( 200 , mobile . NewCursorPage ( items , nextCursor , "" , hasMore ))
}
Bidirectional Cursors
func listMessages ( c * mizu . Ctx ) error {
page := mobile . ParsePageRequest ( c )
before := c . Query ( "before" )
after := c . Query ( "after" )
var messages [] Message
var prevCursor , nextCursor string
if after != "" {
// Load newer messages
afterID , _ := decodeCursor ( after )
messages = db . Query ( `
SELECT * FROM messages
WHERE id > ?
ORDER BY id ASC
LIMIT ?
` , afterID , page . Limit ())
if len ( messages ) > 0 {
nextCursor = encodeCursor ( messages [ len ( messages ) - 1 ]. ID )
prevCursor = encodeCursor ( messages [ 0 ]. ID )
}
} else if before != "" {
// Load older messages
beforeID , _ := decodeCursor ( before )
messages = db . Query ( `
SELECT * FROM messages
WHERE id < ?
ORDER BY id DESC
LIMIT ?
` , beforeID , page . Limit ())
// Reverse for chronological order
slices . Reverse ( messages )
if len ( messages ) > 0 {
nextCursor = encodeCursor ( messages [ len ( messages ) - 1 ]. ID )
prevCursor = encodeCursor ( messages [ 0 ]. ID )
}
} else {
// Initial load - get latest
messages = db . Query ( `
SELECT * FROM messages
ORDER BY id DESC
LIMIT ?
` , page . Limit ())
slices . Reverse ( messages )
if len ( messages ) > 0 {
nextCursor = encodeCursor ( messages [ len ( messages ) - 1 ]. ID )
}
}
hasMore := len ( messages ) == page . Limit ()
return c . JSON ( 200 , mobile . NewCursorPage (
messages ,
nextCursor ,
prevCursor ,
hasMore ,
))
}
Timestamp-Based Cursors
func listNotifications ( c * mizu . Ctx ) error {
page := mobile . ParsePageRequest ( c )
var since time . Time
if page . IsCursor () {
since , _ = time . Parse ( time . RFC3339Nano , page . CursorValue ())
}
notifications := db . Query ( `
SELECT * FROM notifications
WHERE created_at < ?
ORDER BY created_at DESC
LIMIT ?
` , since , page . Limit ())
var nextCursor string
if len ( notifications ) > 0 {
last := notifications [ len ( notifications ) - 1 ]
nextCursor = last . CreatedAt . Format ( time . RFC3339Nano )
}
hasMore := len ( notifications ) == page . Limit ()
return c . JSON ( 200 , mobile . NewCursorPage (
notifications ,
nextCursor ,
"" ,
hasMore ,
))
}
Cursor Encoding
Simple ID Cursors
func encodeCursor ( id int64 ) string {
return base64 . RawURLEncoding . EncodeToString (
[] byte ( strconv . FormatInt ( id , 10 )),
)
}
func decodeCursor ( cursor string ) ( int64 , error ) {
data , err := base64 . RawURLEncoding . DecodeString ( cursor )
if err != nil {
return 0 , err
}
return strconv . ParseInt ( string ( data ), 10 , 64 )
}
Composite Cursors
type Cursor struct {
ID int64 `json:"id"`
CreatedAt time . Time `json:"created_at"`
}
func encodeCursor ( c Cursor ) string {
data , _ := json . Marshal ( c )
return base64 . RawURLEncoding . EncodeToString ( data )
}
func decodeCursor ( s string ) ( Cursor , error ) {
var c Cursor
data , err := base64 . RawURLEncoding . DecodeString ( s )
if err != nil {
return c , err
}
err = json . Unmarshal ( data , & c )
return c , err
}
When to Use Each
Use when:
Displaying page numbers (1, 2, 3…)
Users need to jump to specific pages
Total count is needed
Data is relatively stable
Avoid when:
Real-time feeds with frequent insertions
Large datasets (counting is expensive)
Infinite scroll UX
Use when:
Infinite scroll UI
Real-time feeds
Large datasets
Data changes frequently
Avoid when:
Need to show page numbers
Need to jump to specific pages
Total count is required
Client Implementation
iOS (Swift)
// Page-based
struct PageResponse < T : Decodable >: Decodable {
let data: [T]
let page: Int
let perPage: Int
let total: Int
let totalPages: Int
enum CodingKeys : String , CodingKey {
case data , page , total
case perPage = "per_page"
case totalPages = "total_pages"
}
}
// Cursor-based
struct CursorResponse < T : Decodable >: Decodable {
let data: [T]
let nextCursor: String ?
let prevCursor: String ?
let hasMore: Bool
enum CodingKeys : String , CodingKey {
case data
case nextCursor = "next_cursor"
case prevCursor = "prev_cursor"
case hasMore = "has_more"
}
}
class FeedViewModel : ObservableObject {
@Published var items: [FeedItem] = []
@Published var hasMore = true
@Published var isLoading = false
private var cursor: String ?
func loadMore () async {
guard ! isLoading, hasMore else { return }
isLoading = true
var endpoint = "/api/feed?limit=20"
if let cursor = cursor {
endpoint += "&cursor= \( cursor ) "
}
let response: CursorResponse<FeedItem> = try await api. get (endpoint)
items. append ( contentsOf : response. data )
cursor = response. nextCursor
hasMore = response. hasMore
isLoading = false
}
}
Android (Kotlin)
// Page-based
data class PageResponse < T >(
val data : List < T >,
val page: Int ,
@SerializedName ( "per_page" ) val perPage: Int ,
val total: Int ,
@SerializedName ( "total_pages" ) val totalPages: Int
)
// Cursor-based
data class CursorResponse < T >(
val data : List < T >,
@SerializedName ( "next_cursor" ) val nextCursor: String ?,
@SerializedName ( "prev_cursor" ) val prevCursor: String ?,
@SerializedName ( "has_more" ) val hasMore: Boolean
)
class FeedPagingSource (
private val api: ApiService
) : PagingSource < String , FeedItem >() {
override suspend fun load (params: LoadParams < String >): LoadResult < String , FeedItem > {
return try {
val response = api. getFeed (
cursor = params.key,
limit = params.loadSize
)
LoadResult. Page (
data = response. data ,
prevKey = response.prevCursor,
nextKey = if (response.hasMore) response.nextCursor else null
)
} catch (e: Exception ) {
LoadResult. Error (e)
}
}
}
Flutter (Dart)
class PageResponse < T > {
final List < T > data;
final int page;
final int perPage;
final int total;
final int totalPages;
PageResponse ({
required this .data,
required this .page,
required this .perPage,
required this .total,
required this .totalPages,
});
}
class CursorResponse < T > {
final List < T > data;
final String ? nextCursor;
final String ? prevCursor;
final bool hasMore;
CursorResponse ({
required this .data,
this .nextCursor,
this .prevCursor,
required this .hasMore,
});
}
class FeedNotifier extends StateNotifier < FeedState > {
String ? _cursor;
Future < void > loadMore () async {
if (state.isLoading || ! state.hasMore) return ;
state = state. copyWith (isLoading : true );
final response = await api. getFeed (cursor : _cursor, limit : 20 );
state = state. copyWith (
items : [...state.items, ...response.data],
hasMore : response.hasMore,
isLoading : false ,
);
_cursor = response.nextCursor;
}
}
Next Steps
Offline Sync Delta synchronization for offline-first apps
Structured Errors Consistent error responses
API Reference Complete API documentation