Skip to content

Pagination

pidgn.pagination parses pagination parameters from the request query, clamps them to sensible bounds, and exposes the derived offset/limit plus page metadata for envelope construction and navigation links.

It never fails — invalid or missing values fall back to the configured defaults.

OptionTypeDefaultDescription
default_per_pageu3220Used when the request doesn’t specify.
max_per_pageu32100Upper clamp — a client asking for 9999 gets this instead.
min_per_pageu321Lower clamp.
fn index(ctx: *pidgn.Context) !void {
const p = pidgn.pagination.fromQuery(ctx, .{ .max_per_page = 50 });
// Use with pidgn_db:
const posts = try db.all(Post, .{
.where = .{},
.limit = p.limit,
.offset = p.offset,
});
// Build response envelope:
const total = try db.count(Post, .{});
const meta = p.meta(total);
try ctx.render(IndexTemplate, .ok, .{
.posts = posts,
.page = meta.page,
.total_pages = meta.total_pages,
.has_prev = meta.has_prev,
.has_next = meta.has_next,
});
}

Handlers accept both styles — page wins if both are specified:

GET /posts?page=3&per_page=25

Produces Page{ .page = 3, .per_page = 25, .offset = 50, .limit = 25 }.

Call page.meta(total) where total is the full row count (usually a SELECT COUNT(*)):

pub const Meta = struct {
page: u32,
per_page: u32,
total: u64,
total_pages: u32,
has_prev: bool,
has_next: bool,
};

has_prev / has_next make template-side navigation links a one-liner:

{{#if has_prev}}
<a href="?page={{prev_page}}">Previous</a>
{{/if}}
{{#if has_next}}
<a href="?page={{next_page}}">Next</a>
{{/if}}
  • Invalid input is coerced, not rejected. ?page=abc or ?per_page=-5 both fall back to defaults rather than returning 400. If you want strict validation, use changesets on the query params instead.
  • Very large offset on a large table is slow. This is a database concern, not a pagination-helper concern — use cursor pagination (below) for deep browsing.
  • Stateless. This helper has no database or cache side effects — it only transforms the query string.

Offset/limit is convenient but falls apart on very large tables or data that’s changing underneath the user — skipping OFFSET 50000 is expensive, and insertions shift the page boundaries. Cursor pagination replaces the offset with an opaque “continue from here” token derived from the last row’s sort key.

Pidgn provides the envelope — base64-decoding the cursor off the query and base64-encoding a fresh cursor on the way out. You own what the bytes represent (a last-seen ID, a (timestamp, id) tuple, a JSON blob of sort state, etc).

fn listPosts(ctx: *pidgn.Context) !void {
var c = try pidgn.pagination.cursorFromQuery(ctx.allocator, ctx, .{
.max_per_page = 100,
});
defer c.deinit(ctx.allocator);
// c.limit — clamped page size.
// c.raw — the base64 token as sent (null on first page).
// c.decoded — base64-decoded bytes (null on first page).
const after_id = pidgn.pagination.decodeIntCursor(&c) orelse 0;
const posts = try db.all(Post, .{
.where = .{ .id_gt = after_id },
.order = .id_asc,
.limit = c.limit,
});
// Build the next-page cursor, if any.
const next_cursor: ?[]u8 = if (posts.len > 0)
try pidgn.pagination.encodeIntCursor(ctx.allocator, posts[posts.len - 1].id)
else
null;
try ctx.render(IndexTemplate, .ok, .{
.posts = posts,
.next_cursor = next_cursor orelse "",
});
}
OptionTypeDefaultDescription
default_per_pageu3220
max_per_pageu32100
min_per_pageu321
cursor_param[]const u8"cursor"Query-param name.
per_page_param[]const u8"per_page"Accepts limit too for convenience.
// Generic — opaque bytes go in, URL-safe base64 comes out.
const tok = try pidgn.pagination.encodeCursor(ctx.allocator, "ts:170000:id:42");
// Integer shortcut for the common "last row ID" case.
const tok = try pidgn.pagination.encodeIntCursor(ctx.allocator, last_id);

decodeIntCursor(&cursor) parses the matching token back to i64, or returns null if the token is missing (first page) or malformed.

Use offset/limit whenUse cursor when
Small / bounded result setsLarge result sets
Random page access (?page=10)Sequential / infinite-scroll
Result order changes on writes (user re-sorting)Stable append-only or ID-ordered data
You need total_pages in the envelopeYou only need has_next

Both APIs live side-by-side in pidgn.pagination; pick per endpoint.