From Chaos to Order: Building Secure, Smart Pagination for My Rust Blog
From Chaos to Order: Building Secure, Smart Pagination for My Rust Blog
A journey through modern web development, security-first thinking, and user experience design
The Problem That Started It All
Picture this: You have a personal blog with dozens of articles, and visitors are stuck scrolling through an endless list to find what they're looking for. No pagination, no sorting, no way to filter by topics they care about. It's like having a library where all the books are just... thrown on the floor.
That was my blog three weeks ago. Today, it's an entirely different story.
Why This Matters (Beyond Just "Making Things Pretty")
Before diving into the technical details, let me explain why this project was more than just adding some buttons and dropdowns:
For everyday users: Imagine trying to find a specific recipe in a cookbook with no chapters, no index, and pages scattered randomly. That's what an unpaginated blog feels like.
For me as a developer: This was a masterclass in thinking through edge cases, preventing security vulnerabilities, and building features that genuinely improve the user experience.
The Technical Challenge: More Complex Than It Looks
What seems like a simple feature - "add some page numbers" - quickly becomes a multi-layered engineering challenge:
Layer 1: The Security Minefield
Every user input is a potential attack vector. Page numbers, sort options, search filters - they all need bulletproof validation.
Layer 2: The Performance Puzzle
Database queries that work fine with 10 posts can bring your server to its knees with 10,000 posts.
Layer 3: The User Experience Equation
Fast, intuitive, and smart enough to surface content users actually want to see.
My Solution: A Security-First, Performance-Conscious Approach
The Foundation: Bulletproof Input Validation
Here's where most developers make their first mistake - trusting user input. Instead, I built a validation system that treats every parameter as potentially malicious:
#[derive(Debug)]
struct ValidatedQuery {
page: u32,
page_size: u32,
category: Option<String>,
sort_order: SortOrder,
interests: Vec<String>,
}
impl ValidatedQuery {
fn from_raw(params: ListPostsQuery) -> Result<Self, AppError> {
// Validate page - must be positive, reasonable upper bound
let page = params.page.unwrap_or(1).max(1).min(10000);
// Validate page_size - prevent resource exhaustion
let page_size = params.page_size.unwrap_or(6).max(1).min(50);
// Validate category - only safe characters allowed
let category = params.category.and_then(|cat| {
let trimmed = cat.trim();
if trimmed.is_empty() || trimmed.len() > 100 {
None
} else if trimmed.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == ' ') {
Some(trimmed.to_string())
} else {
None // Invalid characters - silently ignore
}
});
// More validation logic...
}
}
Why this matters: This isn't paranoia - it's professional responsibility. Without proper validation, a malicious user could:
- Crash your server by requesting page 999,999,999
- Inject SQL commands through category filters
- Overwhelm your database with massive page sizes
The Database Strategy: Secure by Design
Instead of building SQL queries with string concatenation (a classic security vulnerability), I used Rust's sqlx
with compile-time checked queries:
// This is checked at compile time - if the SQL is wrong, the code won't build
let total_count: i64 = match &validated.category {
Some(category) => {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM posts WHERE category = ?",
category
)
.fetch_one(&state.db_pool)
.await?.into()
}
None => {
sqlx::query_scalar!("SELECT COUNT(*) FROM posts")
.fetch_one(&state.db_pool)
.await?.into()
}
};
The magic here: The ?
placeholder automatically sanitizes input, making SQL injection impossible. Plus, if I make a typo in the SQL, my code won't even compile.
The Smart Sorting System
Here's where it gets interesting. Basic sorting is easy - newest first, oldest first, most popular. However, I wanted something more advanced: interest-based personalization.
fn apply_interest_based_sorting(posts: &mut [PostPreview], interests: &[String]) {
posts.sort_by(|a, b| {
let a_matches = interests.iter().any(|interest| {
a.category.as_ref()
.map(|cat| cat.to_lowercase().contains(interest))
.unwrap_or(false)
});
let b_matches = interests.iter().any(|interest| {
b.category.as_ref()
.map(|cat| cat.to_lowercase().contains(interest))
.unwrap_or(false)
});
match (a_matches, b_matches) {
(true, false) => std::cmp::Ordering::Less, // a comes first
(false, true) => std::cmp::Ordering::Greater, // b comes first
_ => a.created_at.cmp(&b.created_at).reverse() // fallback to newest
}
});
}
What this does: If you tell the system you're interested in "rust" and "programming", articles about those topics automatically bubble to the top, even if newer articles exist in other categories.
The Pagination Logic: Handling Edge Cases
Pagination math is trickier than you'd think. What happens when someone requests page 0? Or page 999 when you only have 3 pages? Here's how I handled it:
// Calculate pagination info with overflow protection
let total_pages = if total_count > 0 {
((total_count as f64) / (validated.page_size as f64)).ceil() as u32
} else {
1
};
// Generate smart page ranges (show current page ± 2)
let start_page = (current_page.saturating_sub(2)).max(1);
let end_page = (current_page + 2).min(total_pages);
let page_range: Vec<u32> = (start_page..=end_page).collect();
The details matter: Using saturating_sub
prevents integer underflow. The page range logic ensures users always see helpful navigation options without overwhelming them.
The User Experience Layer
Technical excellence means nothing if users hate the interface. Here's what I built:
Smart Filter Controls
<div class="col-md-4">
<label for="interestsInput" class="form-label">
Your Interests <small class="text-muted">(comma-separated)</small>
</label>
<input type="text" class="form-control interests-input"
id="interestsInput" name="interests"
value="{{ interests }}"
placeholder="rust, programming, web">
<div class="form-text">Articles matching your interests will appear first</div>
</div>
Intelligent Pagination UI
The pagination controls aren't just functional - they're smart:
- Show dots (...) when there are many pages
- Always show first and last page options
- Preserve all filters when navigating
- Reset to page 1 when changing filters (prevents "page 5 of 2" errors)
Local Storage Integration
// Remember user preferences
document.addEventListener('DOMContentLoaded', function() {
const interestsInput = document.getElementById('interestsInput');
const storedInterests = localStorage.getItem('userInterests');
if (storedInterests && !interestsInput.value) {
interestsInput.value = storedInterests;
}
interestsInput.addEventListener('blur', function() {
if (this.value.trim()) {
localStorage.setItem('userInterests', this.value.trim());
}
});
});
The Performance Considerations
Every feature comes with a cost. Here's how I kept things fast:
Database Optimization
- Used indexed columns for sorting and filtering
- Implemented LIMIT/OFFSET correctly to avoid scanning unnecessary rows
- Combined multiple queries into efficient single queries where possible
Memory Management
- Limited page sizes to prevent memory exhaustion
- Used Rust's ownership system to avoid unnecessary data copying
- Streamed large results instead of loading everything into memory
Caching Strategy
- Template rendering is cached
- Database connection pooling reduces overhead
- Smart query patterns minimize database round trips
The Security Audit Checklist
Before deploying, I went through every possible attack vector:
✅ SQL Injection Prevention: All queries use parameterized statements
✅ Input Validation: Every user input is sanitized and bounded
✅ Resource Exhaustion Protection: Limits on page sizes, query complexity
✅ XSS Prevention: All user content is properly escaped in templates
✅ CSRF Protection: Built into the form handling system
✅ Rate Limiting: Implicit through database connection pooling
What I Learned (And What You Should Know)
For Developers:
- Security isn't optional - Build it in from day one, not as an afterthought
- User input is evil - Validate everything, trust nothing
- Performance scales differently - What works for 100 records might fail at 10,000
- Edge cases matter - The difference between good and great is handling the weird scenarios
For Users:
The end result is a blog that:
- Loads fast, even with hundreds of posts
- Remembers what you're interested in
- Makes it easy to find relevant content
- Works reliably without crashes or errors
The Bigger Picture
This wasn't just about adding pagination to a blog. It was about:
- Building systems that scale - From 10 posts to 10,000 posts
- Security-first development - Every feature is designed with security in mind
- User-centered design - Technology that serves people, not the other way around
- Professional engineering practices - Code that's maintainable, tested, and documented
Remember: Great software isn't just about making things work - it's about making things work safely, efficiently, and delightfully for the people who use them.