rust
75 lines · 10 steps
Cursor pagination in an Axum handler
An Axum endpoint that pages through articles using an opaque base64 cursor instead of fragile numeric offsets.
Explained by
highlit
1use axum::{extract::{Query, State}, http::StatusCode, Json};
2use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
3use serde::{Deserialize, Serialize};
4use sqlx::PgPool;
5use uuid::Uuid;
6
7#[derive(Deserialize)]
8pub struct PageParams {
9 cursor: Option<String>,
10 #[serde(default = "default_limit")]
11 limit: i64,
12}
13
14fn default_limit() -> i64 {
15 20
16}
17
18#[derive(Serialize, sqlx::FromRow)]
19pub struct Article {
20 id: Uuid,
21 title: String,
22 created_at: chrono::DateTime<chrono::Utc>,
23}
24
25#[derive(Serialize)]
26pub struct Page {
27 items: Vec<Article>,
28 next_cursor: Option<String>,
29}
30
31fn decode_cursor(raw: &str) -> Result<(chrono::DateTime<chrono::Utc>, Uuid), StatusCode> {
32 let bytes = URL_SAFE_NO_PAD.decode(raw).map_err(|_| StatusCode::BAD_REQUEST)?;
33 let text = String::from_utf8(bytes).map_err(|_| StatusCode::BAD_REQUEST)?;
34 let (ts, id) = text.split_once('|').ok_or(StatusCode::BAD_REQUEST)?;
35 let created = ts.parse().map_err(|_| StatusCode::BAD_REQUEST)?;
36 let id = id.parse().map_err(|_| StatusCode::BAD_REQUEST)?;
37 Ok((created, id))
38}
39
40fn encode_cursor(a: &Article) -> String {
41 URL_SAFE_NO_PAD.encode(format!("{}|{}", a.created_at.to_rfc3339(), a.id))
42}
43
44pub async fn list_articles(
45 State(pool): State<PgPool>,
46 Query(params): Query<PageParams>,
47) -> Result<Json<Page>, StatusCode> {
48 let limit = params.limit.clamp(1, 100);
49 let (after_ts, after_id) = match params.cursor {
50 Some(raw) => {
51 let (ts, id) = decode_cursor(&raw)?;
52 (ts, id)
53 }
54 None => (chrono::DateTime::<chrono::Utc>::MAX_UTC, Uuid::max()),
55 };
56
57 let mut rows = sqlx::query_as::<_, Article>(
58 "SELECT id, title, created_at FROM articles \
59 WHERE (created_at, id) < ($1, $2) \
60 ORDER BY created_at DESC, id DESC \
61 LIMIT $3",
62 )
63 .bind(after_ts)
64 .bind(after_id)
65 .bind(limit + 1)
66 .fetch_all(&pool)
67 .await
68 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
69
70 let next_cursor = (rows.len() as i64 > limit)
71 .then(|| encode_cursor(&rows[limit as usize - 1]));
72 rows.truncate(limit as usize);
73
74 Ok(Json(Page { items: rows, next_cursor }))
75}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Keyset pagination compares a composite (timestamp, id) key so results stay stable even as rows are inserted.
- 2Encoding the sort key into an opaque cursor hides pagination internals and survives round-trips through the client.
- 3Fetching limit+1 rows is a cheap trick to detect whether another page exists without a second count query.
Related explainers
rust
use std::fmt; #[derive(Clone, Copy, PartialEq, Eq)] pub struct Money {
Formatting money with Rust's Display trait
trait-implementation
integer-arithmetic
enums
Intermediate
10 steps
rust
use std::collections::HashSet; use std::io::{self, BufRead, BufWriter, Write}; fn main() -> io::Result<()> {
Filtering duplicate lines in Rust
stdin
hashset
buffering
Beginner
5 steps
python
import os import uuid from flask import Blueprint, current_app, jsonify, request
Handling secure file uploads in Flask
file-upload
validation
blueprints
Intermediate
8 steps
java
package com.example.orders.messaging; import com.example.orders.dto.OrderEvent; import com.example.orders.service.OrderProcessingService;
Manual RabbitMQ acks in a Spring listener
message-queue
error-handling
acknowledgement
Intermediate
6 steps
rust
use std::num::ParseIntError; fn parse_line(line: &str) -> Result<Vec<i64>, ParseIntError> { line.split(',')
Collecting Results in Rust iterators
iterators
error-handling
result
Intermediate
7 steps
rust
use std::sync::Arc; use axum::{ extract::State,
Offloading work with a channel in Axum
background-jobs
message-passing
shared-state
Intermediate
9 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/cursor-pagination-in-an-axum-handler-explained-rust-e199/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.