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

Walkthrough

Space play step click any line
Three takeaways
  1. 1Keyset pagination compares a composite (timestamp, id) key so results stay stable even as rows are inserted.
  2. 2Encoding the sort key into an opaque cursor hides pagination internals and survives round-trips through the client.
  3. 3Fetching limit+1 rows is a cheap trick to detect whether another page exists without a second count query.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Cursor pagination in an Axum handler — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code