Why typed pagination helps

Many APIs expose pagination in one of two forms:

  • Offset-based: page=3&limit=50
  • Cursor-based: cursor=eyJpZCI6...&limit=50

Offset-based pagination is easy to understand, but it can become unstable when rows are inserted or deleted between requests. Cursor-based pagination is usually more reliable for live datasets because it advances from a known item rather than a numeric offset.

The problem is that cursor-based pagination often ends up represented as raw strings. That makes it easy to:

  • pass a cursor to the wrong endpoint,
  • mix cursors from different sort orders,
  • forget to validate page size,
  • accidentally expose internal database IDs.

A typed API can encode those rules directly.


What we are building

We’ll implement a small library that models:

  • PageSize: a validated page size
  • SortOrder: ascending or descending
  • Cursor<T>: an opaque cursor tied to a specific item type
  • PageRequest<T>: a complete pagination request
  • Page<T>: a paginated response with next/previous cursors

This design is useful in API clients, repository layers, and service code where pagination logic should be explicit and safe.

TypePurposeBenefit
PageSizeValidated page sizePrevents invalid limits like 0 or 10_000
SortOrderSort directionMakes ordering explicit
Cursor<T>Opaque pagination tokenPrevents mixing cursors across domains
PageRequest<T>Request parametersKeeps pagination inputs consistent
Page<T>Response wrapperStandardizes pagination output

Defining the core types

Let’s start with the basic building blocks.

use std::marker::PhantomData;
use std::num::NonZeroU32;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortOrder {
    Asc,
    Desc,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PageSize(NonZeroU32);

impl PageSize {
    pub fn new(value: u32) -> Result<Self, &'static str> {
        if value == 0 {
            return Err("page size must be greater than zero");
        }
        if value > 100 {
            return Err("page size must not exceed 100");
        }
        Ok(Self(NonZeroU32::new(value).unwrap()))
    }

    pub fn get(self) -> u32 {
        self.0.get()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cursor<T> {
    token: String,
    _marker: PhantomData<T>,
}

impl<T> Cursor<T> {
    pub fn new(token: impl Into<String>) -> Self {
        Self {
            token: token.into(),
            _marker: PhantomData,
        }
    }

    pub fn as_str(&self) -> &str {
        &self.token
    }
}

A few details matter here:

  • PageSize is validated at construction time.
  • Cursor<T> uses PhantomData<T> to bind the cursor to a specific domain type.
  • The cursor remains opaque; callers can pass it around, but the internal token is not interpreted by application code.

That last point is important. In a real system, the token might be a base64-encoded payload, a signed value, or a database-specific marker. The API should not force consumers to know the encoding.


Modeling a paginated request

A request usually needs a page size, an optional cursor, and a sort order. We can package those together.

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageRequest<T> {
    pub limit: PageSize,
    pub cursor: Option<Cursor<T>>,
    pub order: SortOrder,
}

impl<T> PageRequest<T> {
    pub fn first_page(limit: PageSize, order: SortOrder) -> Self {
        Self {
            limit,
            cursor: None,
            order,
        }
    }

    pub fn with_cursor(limit: PageSize, cursor: Cursor<T>, order: SortOrder) -> Self {
        Self {
            limit,
            cursor: Some(cursor),
            order,
        }
    }
}

This API makes invalid states harder to express:

  • You cannot accidentally pass a cursor intended for another entity type.
  • You cannot forget to specify a page size.
  • You can clearly distinguish the first page from subsequent pages.

To make the example concrete, imagine a User list endpoint.

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
    pub id: u64,
    pub email: String,
}

A PageRequest<User> now clearly belongs to user pagination.


Returning typed page results

A paginated response should include the current items and enough metadata to fetch the next or previous page.

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Page<T> {
    pub items: Vec<T>,
    pub next_cursor: Option<Cursor<T>>,
    pub prev_cursor: Option<Cursor<T>>,
    pub has_more: bool,
}

impl<T> Page<T> {
    pub fn empty() -> Self {
        Self {
            items: Vec::new(),
            next_cursor: None,
            prev_cursor: None,
            has_more: false,
        }
    }
}

The response type is intentionally simple. In many applications, the cursor values are enough; a separate total count is often expensive and unnecessary for cursor-based pagination.

Why not expose raw strings?

Because raw strings do not communicate intent. A String could be a cursor, a search term, a user ID, or a JSON blob. A Cursor<User> says exactly what it is and where it belongs.


Implementing a repository-style paginator

Now let’s simulate a data source. In a real application, this would be a database query or an HTTP API call. For the tutorial, we’ll use an in-memory list and a simple cursor scheme.

We’ll encode the cursor as the last seen user ID. This is not a universal solution, but it demonstrates the mechanics.

pub struct UserRepository {
    users: Vec<User>,
}

impl UserRepository {
    pub fn new(mut users: Vec<User>) -> Self {
        users.sort_by_key(|u| u.id);
        Self { users }
    }

    pub fn list_users(&self, request: PageRequest<User>) -> Page<User> {
        let limit = request.limit.get() as usize;

        let start_index = match request.cursor {
            None => 0,
            Some(cursor) => {
                let last_id: u64 = cursor.as_str().parse().unwrap_or(0);
                self.users
                    .iter()
                    .position(|u| u.id == last_id)
                    .map(|idx| idx + 1)
                    .unwrap_or(0)
            }
        };

        let items: Vec<User> = match request.order {
            SortOrder::Asc => self.users.iter().skip(start_index).take(limit).cloned().collect(),
            SortOrder::Desc => self.users.iter().rev().skip(start_index).take(limit).cloned().collect(),
        };

        let has_more = items.len() == limit;

        let next_cursor = items.last().map(|u| Cursor::new(u.id.to_string()));
        let prev_cursor = items.first().map(|u| Cursor::new(u.id.to_string()));

        Page {
            items,
            next_cursor,
            prev_cursor,
            has_more,
        }
    }
}

This implementation is intentionally straightforward, but it highlights a few best practices:

  • Sort the dataset before paginating.
  • Use the cursor to resume from a known item.
  • Return has_more so callers can decide whether to request another page.
  • Keep cursor creation centralized.

In a production system, you would likely replace the parse().unwrap_or(0) logic with a proper decoder and validation step.


Using the API from application code

Here is how a caller might use the repository.

fn main() -> Result<(), &'static str> {
    let repo = UserRepository::new(vec![
        User { id: 1, email: "[email protected]".into() },
        User { id: 2, email: "[email protected]".into() },
        User { id: 3, email: "[email protected]".into() },
        User { id: 4, email: "[email protected]".into() },
    ]);

    let limit = PageSize::new(2)?;
    let first = repo.list_users(PageRequest::first_page(limit, SortOrder::Asc));

    println!("first page: {:?}", first.items);

    if let Some(cursor) = first.next_cursor {
        let second = repo.list_users(PageRequest::with_cursor(limit, cursor, SortOrder::Asc));
        println!("second page: {:?}", second.items);
    }

    Ok(())
}

This call site is readable and safe:

  • PageSize::new(2)? validates the limit.
  • PageRequest::first_page(...) makes the initial request explicit.
  • PageRequest::with_cursor(...) requires a cursor of the correct type.

Improving cursor safety with encoding

The example above uses a plain ID string as a cursor token. That is fine for learning, but real APIs often need stronger guarantees. A cursor may need to be:

  • opaque to clients,
  • signed to prevent tampering,
  • versioned for future schema changes,
  • tied to a sort key and direction.

A practical approach is to define a dedicated cursor payload and serialize it.

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CursorPayload {
    pub last_id: u64,
    pub order: SortOrder,
    pub version: u8,
}

You could then encode this payload as JSON, base64, or a compact binary format. The important design principle is the same: keep the cursor type separate from the token representation.

Recommended cursor rules

  • Treat cursors as opaque outside the pagination layer.
  • Include the sort key in the cursor payload.
  • Reject cursors that do not match the current sort order.
  • Version the payload so you can evolve it later.

Handling edge cases

Typed pagination does not remove all complexity, but it makes the tricky parts visible.

Empty result sets

An empty page should still be a valid page. Return an empty items vector and has_more = false.

Deleted or missing cursor targets

If the item referenced by a cursor no longer exists, you need a policy:

  • restart from the beginning,
  • return an error,
  • or resume from the nearest valid position.

The right choice depends on your API contract. For user-facing APIs, returning a clear error is often better than silently changing behavior.

Changing sort order

A cursor generated for ascending order should not be reused for descending order. You can enforce this by storing SortOrder in the cursor payload and validating it before querying.

Page size limits

Always cap page size. Without a limit, a client can request a huge page and create unnecessary load.


When this pattern is a good fit

Typed pagination is especially useful when:

  • you have multiple list endpoints with different entities,
  • cursors must not be mixed across resources,
  • pagination logic is shared between services and clients,
  • you want to reduce accidental misuse in application code.

It may be overkill for tiny scripts or one-off internal tools. But for APIs that evolve over time, the extra structure pays off quickly.


Summary

A typed pagination cursor API helps you express pagination rules directly in Rust types instead of relying on conventions and raw strings. By separating PageSize, Cursor<T>, PageRequest<T>, and Page<T>, you make the code easier to validate, easier to read, and harder to misuse.

The core ideas are simple:

  • validate limits early,
  • keep cursors opaque,
  • bind cursors to a specific entity type,
  • include sort order in the pagination model,
  • centralize cursor encoding and decoding.

That combination gives you a pagination layer that is practical for real applications and robust enough to evolve.

Learn more with useful resources