Rust Web Server with Axum 0.8: Pagination, Filters and Sorting with Query
November 3, 2026
Episode 14 of the Rust Backend series, where we build the backend of a blog step by step. After episode 13 our posts have slugs and tags. But /posts still returns every post: with ten thousand posts, that is one huge answer. Today: pages, filters and sorting, all from the URL. All the code below is the final, tested code from the video, ready to copy.
- New to Rust? Start with the beginner series.
- The full backend series playlist is here.
We continue in the same blog_api project from episode 13. No new crates today. The examples use jq to keep the output short (sudo apt install jq).
1. Some data
First, 25 posts. Every third one is tagged rust:
for i in $(seq 1 25); do t='[]'; [ $((i%3)) = 0 ] && t='["rust"]'; curl -s localhost:8080/posts -H 'content-type: application/json' -d "{\"title\":\"Post $i\",\"body\":\"Body $i\",\"tags\":$t}" > /dev/null; done
curl -s localhost:8080/posts | head -c 400All 25 posts come back in one answer.
2. The query string and Query
The part after the ? is the query string:
/posts?page=2&per_page=10&tag=rust&sort=titleaxum's Query extractor turns it into a struct, just like Json does with the body. In models.rs:
#[derive(Deserialize)]
pub struct ListParams {
pub page: Option<u32>,
pub per_page: Option<u32>,
pub tag: Option<String>,
#[serde(default)]
pub sort: Sort,
}Every field is optional, so /posts without a query string still works.
3. Sorting with an enum
sort is an enum, not a string. rename_all = "lowercase" lets the URL say newest in small letters, and anything else is rejected by serde before our code runs:
#[derive(Deserialize, Clone, Copy, Default)]
#[serde(rename_all = "lowercase")]
pub enum Sort {
#[default]
Newest,
Oldest,
Title,
}4. The handler and the Page type
The answer becomes a Page: the items, the page number, the page size and the total.
#[derive(Serialize)]
pub struct Page<T> {
pub items: Vec<T>,
pub page: u32,
pub per_page: u32,
pub total: i64,
}
pub struct ListFilter {
pub page: u32,
pub per_page: u32,
pub tag: Option<String>,
pub sort: Sort,
}list_posts takes Query<ListParams>. page defaults to 1, per_page to 10, and clamp keeps it between 1 and 100, so nobody can ask for a million rows:
pub async fn list_posts<R: PostRepo>(
State(repo): State<R>,
Query(p): Query<ListParams>,
) -> Result<Json<Page<Post>>, ApiError> {
let page = p.page.unwrap_or(1).max(1);
let per_page = p.per_page.unwrap_or(10).clamp(1, 100);
let (items, total) = repo.list(ListFilter { page, per_page, tag: p.tag, sort: p.sort }).await?;
Ok(Json(Page { items, page, per_page, total }))
}In the trait, list now takes the filter and returns the posts plus the total:
fn list(&self, f: ListFilter) -> impl Future<Output = sqlx::Result<(Vec<Post>, i64)>> + Send;5. QueryBuilder
Our SQL now changes with the filters, so query_as! (which checks one fixed query at compile time) cannot be used here. SQLx's QueryBuilder builds the query piece by piece: push adds SQL text, push_bind adds a value as a ? placeholder.
async fn list(&self, f: ListFilter) -> sqlx::Result<(Vec<Post>, i64)> {
let mut q = QueryBuilder::<Sqlite>::new("SELECT p.id, p.title, p.body, p.slug, p.created_at FROM posts p");
if let Some(tag) = &f.tag {
q.push(" JOIN post_tags pt ON pt.post_id = p.id JOIN tags t ON t.id = pt.tag_id WHERE t.name = ")
.push_bind(tag.clone());
}
q.push(match f.sort {
Sort::Newest => " ORDER BY p.id DESC",
Sort::Oldest => " ORDER BY p.id ASC",
Sort::Title => " ORDER BY p.title ASC",
});
q.push(" LIMIT ").push_bind(f.per_page as i64);
q.push(" OFFSET ").push_bind(((f.page - 1) * f.per_page) as i64);
let posts = q.build_query_as::<Post>().fetch_all(&self.pool).await?;
let mut c = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM posts p");
if let Some(tag) = &f.tag {
c.push(" JOIN post_tags pt ON pt.post_id = p.id JOIN tags t ON t.id = pt.tag_id WHERE t.name = ")
.push_bind(tag.clone());
}
let total: i64 = c.build_query_scalar().fetch_one(&self.pool).await?;
Ok((posts, total))
}Three things to notice:
- ORDER BY comes from the
Sortenum, so only our three fixed strings can ever reach the SQL. Neverpushuser text into SQL; user values always go throughpush_bind. - LIMIT and OFFSET pick the page: page 2 with 10 per page skips the first 10.
- A second query counts all matching posts, with the same filter, so the client knows how many pages there are.
Add use sqlx::{QueryBuilder, Sqlite, SqlitePool}; at the top of repo.rs.
6. Try it
cargo run
curl -s localhost:8080/posts | jq '{page, per_page, total, n: (.items | length)}'{
"page": 1,
"per_page": 10,
"total": 25,
"n": 10
}Ten items on page 1, 25 in total. Page 3 has the last five:
curl -s 'localhost:8080/posts?page=3' | jq '{page, total, n: (.items | length)}'{
"page": 3,
"total": 25,
"n": 5
}Only posts tagged rust, sorted by title:
curl -s 'localhost:8080/posts?tag=rust&sort=title' | jq '[.items[].title]'[
"Post 12",
"Post 15",
"Post 18",
"Post 21",
"Post 24",
"Post 3",
"Post 6",
"Post 9"
](The titles are sorted as text, so "Post 12" comes before "Post 3".)
7. A bad page: plain text again
Now a mistake:
curl -i 'localhost:8080/posts?page=abc'HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
Failed to deserialize query string: page: invalid digit found in stringA 400 is right, but it is plain text, not our JSON error. Query has its own rejection type, QueryRejection, and we have not caught it yet.
8. BadQuery
The same trick as for Json in episode 5: a variant with #[from] in error.rs:
#[error(transparent)]
BadQuery(#[from] QueryRejection),It keeps the rejection's status and text, like BadJson:
ApiError::BadQuery(rejection) => rejection.status(),
// ...
ApiError::BadQuery(rejection) => rejection.body_text(),and the handler takes a Result, so a rejection becomes our error with ?:
pub async fn list_posts<R: PostRepo>(
State(repo): State<R>,
params: Result<Query<ListParams>, QueryRejection>,
) -> Result<Json<Page<Post>>, ApiError> {
let Query(p) = params?;
// ...
}Run again:
cargo run
curl -i 'localhost:8080/posts?page=abc'HTTP/1.1 400 Bad Request
content-type: application/json
{"error":"Failed to deserialize query string: page: invalid digit found in string"}And an attempt to sneak SQL into the sort:
curl -i 'localhost:8080/posts?sort=drop%20table'HTTP/1.1 400 Bad Request
content-type: application/json
{"error":"Failed to deserialize query string: sort: unknown variant `drop table`, expected one of `newest`, `oldest`, `title`"}Our enum protected the SQL: the text never got near the database.
The final code
src/error.rs
use axum::extract::rejection::{JsonRejection, QueryRejection};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("post {0} not found")]
NotFound(i64),
#[error("title must not be empty")]
EmptyTitle,
#[error(transparent)]
BadJson(#[from] JsonRejection),
#[error(transparent)]
BadQuery(#[from] QueryRejection),
#[error("database error")]
Database(#[from] sqlx::Error),
#[error("{0}")]
Conflict(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
tracing::warn!(error = %self, "request failed");
let status = match &self {
ApiError::NotFound(_) => StatusCode::NOT_FOUND,
ApiError::Conflict(_) => StatusCode::CONFLICT,
ApiError::EmptyTitle => StatusCode::BAD_REQUEST,
ApiError::BadJson(rejection) => rejection.status(),
ApiError::BadQuery(rejection) => rejection.status(),
ApiError::Database(err) => {
tracing::error!(?err, "database error");
StatusCode::INTERNAL_SERVER_ERROR
}
};
let message = match &self {
ApiError::BadJson(rejection) => rejection.body_text(),
ApiError::BadQuery(rejection) => rejection.body_text(),
other => other.to_string(),
};
(status, Json(json!({ "error": message }))).into_response()
}
}src/handlers.rs
use axum::extract::{rejection::{JsonRejection, QueryRejection}, Path, Query, State};
use axum::{http::StatusCode, Json};
use crate::error::ApiError;
use crate::models::{Comment, ListFilter, ListParams, NewComment, NewPost, Page, Post, PostWithTags};
use crate::repo::PostRepo;
fn check(new: &NewPost) -> Result<(), ApiError> {
if new.title.trim().is_empty() {
return Err(ApiError::EmptyTitle);
}
Ok(())
}
#[tracing::instrument(skip(repo, payload))]
pub async fn create_post<R: PostRepo>(
State(repo): State<R>,
payload: Result<Json<NewPost>, JsonRejection>,
) -> Result<(StatusCode, Json<PostWithTags>), ApiError> {
let Json(new) = payload?;
check(&new)?;
let post = repo.create(new).await.map_err(|err| match err.as_database_error() {
Some(e) if e.is_unique_violation() => ApiError::Conflict("a post with this title already exists".into()),
_ => ApiError::Database(err),
})?;
tracing::debug!(id = post.post.id, "created");
Ok((StatusCode::CREATED, Json(post)))
}
pub async fn get_post<R: PostRepo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<Json<Post>, ApiError> {
let post = repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
Ok(Json(post))
}
pub async fn list_posts<R: PostRepo>(
State(repo): State<R>,
params: Result<Query<ListParams>, QueryRejection>,
) -> Result<Json<Page<Post>>, ApiError> {
let Query(p) = params?;
let page = p.page.unwrap_or(1).max(1);
let per_page = p.per_page.unwrap_or(10).clamp(1, 100);
let (items, total) = repo.list(ListFilter { page, per_page, tag: p.tag, sort: p.sort }).await?;
Ok(Json(Page { items, page, per_page, total }))
}
pub async fn update_post<R: PostRepo>(
State(repo): State<R>,
Path(id): Path<i64>,
payload: Result<Json<NewPost>, JsonRejection>,
) -> Result<Json<Post>, ApiError> {
let Json(new) = payload?;
check(&new)?;
let post = repo.update(id, new).await?.ok_or(ApiError::NotFound(id))?;
Ok(Json(post))
}
pub async fn delete_post<R: PostRepo>(State(repo): State<R>, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
if !repo.delete(id).await? {
return Err(ApiError::NotFound(id));
}
Ok(StatusCode::NO_CONTENT)
}
pub async fn list_comments<R: PostRepo>(
State(repo): State<R>,
Path(id): Path<i64>,
) -> Result<Json<Vec<Comment>>, ApiError> {
repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
Ok(Json(repo.list_comments(id).await?))
}
pub async fn add_comment<R: PostRepo>(
State(repo): State<R>,
Path(id): Path<i64>,
payload: Result<Json<NewComment>, JsonRejection>,
) -> Result<(StatusCode, Json<Comment>), ApiError> {
let Json(new) = payload?;
let comment = repo.add_comment(id, new).await.map_err(|err| {
match err.as_database_error() {
Some(db_err) if db_err.is_foreign_key_violation() => ApiError::NotFound(id),
_ => ApiError::Database(err),
}
})?;
Ok((StatusCode::CREATED, Json(comment)))
}src/models.rs
use serde::{Deserialize, Serialize};
#[derive(Serialize, Clone, sqlx::FromRow)]
pub struct Post {
pub id: i64,
pub title: String,
pub body: String,
pub slug: String,
pub created_at: String,
}
#[derive(Deserialize)]
pub struct NewPost {
pub title: String,
pub body: String,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Serialize, Clone)]
pub struct Comment {
pub id: i64,
pub post_id: i64,
pub body: String,
pub created_at: String,
}
#[derive(Deserialize)]
pub struct NewComment {
pub body: String,
}
pub fn slugify(title: &str) -> String {
let mut slug = String::new();
for c in title.trim().to_lowercase().chars() {
if c.is_alphanumeric() {
slug.push(c);
} else if !slug.ends_with('-') {
slug.push('-');
}
}
slug.trim_end_matches('-').to_string()
}
#[derive(Serialize)]
pub struct PostWithTags {
#[serde(flatten)]
pub post: Post,
pub tags: Vec<String>,
}
#[derive(Deserialize)]
pub struct ListParams {
pub page: Option<u32>,
pub per_page: Option<u32>,
pub tag: Option<String>,
#[serde(default)]
pub sort: Sort,
}
#[derive(Deserialize, Clone, Copy, Default)]
#[serde(rename_all = "lowercase")]
pub enum Sort {
#[default]
Newest,
Oldest,
Title,
}
#[derive(Serialize)]
pub struct Page<T> {
pub items: Vec<T>,
pub page: u32,
pub per_page: u32,
pub total: i64,
}
pub struct ListFilter {
pub page: u32,
pub per_page: u32,
pub tag: Option<String>,
pub sort: Sort,
}src/repo.rs
use std::future::Future;
use sqlx::{QueryBuilder, Sqlite, SqlitePool};
use crate::models::{slugify, Comment, ListFilter, NewComment, NewPost, Post, PostWithTags, Sort};
pub trait PostRepo: Clone + Send + Sync + 'static {
fn list(&self, f: ListFilter) -> impl Future<Output = sqlx::Result<(Vec<Post>, i64)>> + Send;
fn get(&self, id: i64) -> impl Future<Output = sqlx::Result<Option<Post>>> + Send;
fn create(&self, new: NewPost) -> impl Future<Output = sqlx::Result<PostWithTags>> + Send;
fn update(&self, id: i64, new: NewPost) -> impl Future<Output = sqlx::Result<Option<Post>>> + Send;
fn delete(&self, id: i64) -> impl Future<Output = sqlx::Result<bool>> + Send;
fn list_comments(&self, post_id: i64) -> impl Future<Output = sqlx::Result<Vec<Comment>>> + Send;
fn add_comment(&self, post_id: i64, new: NewComment) -> impl Future<Output = sqlx::Result<Comment>> + Send;
}
#[derive(Clone)]
pub struct SqlitePostRepo {
pub pool: SqlitePool,
}
impl PostRepo for SqlitePostRepo {
async fn list(&self, f: ListFilter) -> sqlx::Result<(Vec<Post>, i64)> {
let mut q = QueryBuilder::<Sqlite>::new("SELECT p.id, p.title, p.body, p.slug, p.created_at FROM posts p");
if let Some(tag) = &f.tag {
q.push(" JOIN post_tags pt ON pt.post_id = p.id JOIN tags t ON t.id = pt.tag_id WHERE t.name = ")
.push_bind(tag.clone());
}
q.push(match f.sort {
Sort::Newest => " ORDER BY p.id DESC",
Sort::Oldest => " ORDER BY p.id ASC",
Sort::Title => " ORDER BY p.title ASC",
});
q.push(" LIMIT ").push_bind(f.per_page as i64);
q.push(" OFFSET ").push_bind(((f.page - 1) * f.per_page) as i64);
let posts = q.build_query_as::<Post>().fetch_all(&self.pool).await?;
let mut c = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM posts p");
if let Some(tag) = &f.tag {
c.push(" JOIN post_tags pt ON pt.post_id = p.id JOIN tags t ON t.id = pt.tag_id WHERE t.name = ")
.push_bind(tag.clone());
}
let total: i64 = c.build_query_scalar().fetch_one(&self.pool).await?;
Ok((posts, total))
}
async fn get(&self, id: i64) -> sqlx::Result<Option<Post>> {
sqlx::query_as!(Post, "SELECT id, title, body, slug, created_at FROM posts WHERE id = ?", id)
.fetch_optional(&self.pool)
.await
}
async fn create(&self, new: NewPost) -> sqlx::Result<PostWithTags> {
let slug = slugify(&new.title);
let mut tx = self.pool.begin().await?;
let post = sqlx::query_as!(
Post,
r#"INSERT INTO posts (title, body, slug) VALUES (?, ?, ?) RETURNING id, title, body, slug, created_at"#,
new.title,
new.body,
slug
)
.fetch_one(&mut *tx)
.await?;
for tag in &new.tags {
sqlx::query!("INSERT INTO tags (name) VALUES (?) ON CONFLICT (name) DO NOTHING", tag)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO post_tags (post_id, tag_id) SELECT ?, id FROM tags WHERE name = ?",
post.id,
tag
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(PostWithTags { post, tags: new.tags })
}
async fn update(&self, id: i64, new: NewPost) -> sqlx::Result<Option<Post>> {
sqlx::query_as!(
Post,
r#"UPDATE posts SET title = ?, body = ? WHERE id = ? RETURNING id, title, body, slug, created_at"#,
new.title,
new.body,
id
)
.fetch_optional(&self.pool)
.await
}
async fn delete(&self, id: i64) -> sqlx::Result<bool> {
let result = sqlx::query!("DELETE FROM posts WHERE id = ?", id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
async fn list_comments(&self, post_id: i64) -> sqlx::Result<Vec<Comment>> {
sqlx::query_as!(
Comment,
"SELECT id, post_id, body, created_at FROM comments WHERE post_id = ? ORDER BY id",
post_id
)
.fetch_all(&self.pool)
.await
}
async fn add_comment(&self, post_id: i64, new: NewComment) -> sqlx::Result<Comment> {
sqlx::query_as!(
Comment,
r#"INSERT INTO comments (post_id, body) VALUES (?, ?) RETURNING id as "id!", post_id, body, created_at"#,
post_id,
new.body
)
.fetch_one(&self.pool)
.await
}
}Recap
Query<T>turns the query string into a struct;Optionfields with defaults keep/postsworking- An enum with
rename_all = "lowercase"accepts only the sort values we allow QueryBuilder:pushfor our own SQL text,push_bindfor every user valueLIMITandOFFSETpick the page; a secondCOUNT(*)gives the totalResult<Query<T>, QueryRejection>plus a#[from]variant gives a JSON 400 for bad query strings
Next episode: search. SQLite's full-text search finds posts by their words, and deleted posts go to a trash instead of disappearing. Watch the series on YouTube.