Pagination, filtering, and sorting are not independent features — they form a single cohesive query system that must be consistent between the Angular UI state and the Express query parameters. When a user sets a status filter, changes the sort order, and navigates to page 3, all three states need to be reflected in the URL query string simultaneously, preserved on back-navigation, and correctly sent to the API. This lesson builds the complete full-stack pagination and filtering pattern — from query parameter serialisation in Angular to efficient MongoDB query construction in Express.
Query Parameter Conventions
| Parameter | Type | Example | Default |
|---|---|---|---|
page |
integer | ?page=3 |
1 |
limit |
integer | ?limit=25 |
10 |
sort |
string | ?sort=-createdAt (prefix - = desc) |
-createdAt |
status |
enum | ?status=pending |
all |
priority |
enum | ?priority=high |
all |
q |
string | ?q=client+meeting |
none |
tags |
string[] | ?tags=urgent&tags=Q4 |
all |
from |
ISO date | ?from=2025-01-01 |
none |
to |
ISO date | ?to=2025-01-31 |
none |
ActivatedRoute.queryParamMap, applies them to the filter signals, and loads data. When filters change, navigate with queryParamsHandling: 'merge' to update the URL without a full navigation.queryParams: { page: 1, status: newStatus } — the explicit page: 1 overrides the current page in queryParamsHandling: 'merge'.sort parameter especially is dangerous — if used directly in a MongoDB .sort(req.query.sort) call, an attacker could inject sort=password to sort by password hash, leaking comparative information. Build a whitelist of allowed sort fields: const ALLOWED_SORT = ['createdAt', 'priority', 'dueDate', 'title'] and check before applying. Use numeric values not strings: { [field]: dir === 'desc' ? -1 : 1 }.Complete Pagination and Filtering
// ── Express: robust query parser ─────────────────────────────────────────
const ALLOWED_SORT_FIELDS = ['createdAt', 'updatedAt', 'priority', 'dueDate', 'title'];
const ALLOWED_STATUSES = ['pending', 'in-progress', 'completed'];
const ALLOWED_PRIORITIES = ['low', 'medium', 'high'];
function parseTaskQuery(query) {
// Pagination
const page = Math.max(1, parseInt(query.page) || 1);
const limit = Math.min(50, Math.max(1, parseInt(query.limit) || 10));
const skip = (page - 1) * limit;
// Sorting — prevent injection
let sortField = 'createdAt';
let sortDir = -1;
if (query.sort) {
const raw = query.sort.toString();
const field = raw.startsWith('-') ? raw.slice(1) : raw;
const dir = raw.startsWith('-') ? -1 : 1;
if (ALLOWED_SORT_FIELDS.includes(field)) {
sortField = field;
sortDir = dir;
}
}
// Filters
const filter = { deletedAt: { $exists: false } };
if (query.status && ALLOWED_STATUSES.includes(query.status)) {
filter.status = query.status;
}
if (query.priority && ALLOWED_PRIORITIES.includes(query.priority)) {
filter.priority = query.priority;
}
if (query.q && typeof query.q === 'string' && query.q.trim()) {
filter.$text = { $search: query.q.trim().substring(0, 100) }; // limit length
}
if (query.tags) {
const tags = Array.isArray(query.tags) ? query.tags : [query.tags];
filter.tags = { $all: tags.slice(0, 10).map(t => t.toString()) };
}
if (query.from || query.to) {
filter.createdAt = {};
if (query.from) filter.createdAt.$gte = new Date(query.from);
if (query.to) filter.createdAt.$lte = new Date(query.to);
}
return {
filter, page, limit, skip,
sort: { [sortField]: sortDir },
};
}
// ── Task list endpoint ────────────────────────────────────────────────────
exports.getAll = asyncHandler(async (req, res) => {
const { filter, page, limit, skip, sort } = parseTaskQuery(req.query);
filter.user = req.user.sub; // always scope to current user
const [tasks, total] = await Promise.all([
Task.find(filter).sort(sort).skip(skip).limit(limit).lean(),
Task.countDocuments(filter),
]);
paginated(res, tasks, total, page, limit);
});
// ── Angular: URL-based filter + pagination state ──────────────────────────
@Component({
selector: 'app-task-list',
standalone: true,
template: `
<!-- Filter toolbar -->
<div class="filters">
<select (change)="setFilter('status', $any($event.target).value)"
[value]="status()">
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="in-progress">In Progress</option>
<option value="completed">Completed</option>
</select>
<select (change)="setSort($any($event.target).value)"
[value]="sort()">
<option value="-createdAt">Newest First</option>
<option value="createdAt">Oldest First</option>
<option value="-priority">Priority (High → Low)</option>
<option value="dueDate">Due Date</option>
</select>
<input type="search" [value]="query()"
(input)="onSearch($any($event.target).value)"
placeholder="Search tasks...">
</div>
<!-- Task list -->
<app-spinner *ngIf="store.isLoading()"></app-spinner>
@for (task of store.tasks(); track task._id) {
<app-task-card [task]="task"></app-task-card>
}
@if (store.isEmpty()) { <p>No tasks found.</p> }
<!-- Pagination -->
@if (store.meta(); as meta) {
<div class="pagination">
<button [disabled]="!meta.hasPrevPage" (click)="goToPage(page() - 1)">←</button>
<span>Page {{ meta.page }} of {{ meta.totalPages }} ({{ meta.total }} tasks)</span>
<button [disabled]="!meta.hasNextPage" (click)="goToPage(page() + 1)">→</button>
</div>
}
`,
})
export class TaskListComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
store = inject(TaskStore);
// Signals read from URL query params
page = signal(1);
status = signal('');
priority= signal('');
sort = signal('-createdAt');
query = signal('');
private searchDebounce: ReturnType<typeof setTimeout> | null = null;
ngOnInit(): void {
// Sync signals from URL query params on init
this.route.queryParamMap.pipe(
takeUntilDestroyed(),
).subscribe(params => {
this.page.set(parseInt(params.get('page') ?? '1'));
this.status.set(params.get('status') ?? '');
this.priority.set(params.get('priority') ?? '');
this.sort.set(params.get('sort') ?? '-createdAt');
this.query.set(params.get('q') ?? '');
// Load tasks whenever URL params change
this.store.load({
page: this.page(),
limit: 10,
status: this.status() || undefined,
priority: this.priority() || undefined,
sort: this.sort(),
q: this.query() || undefined,
});
});
}
// Update URL → triggers queryParamMap → triggers store.load()
setFilter(key: string, value: string): void {
this.router.navigate([], {
queryParams: { [key]: value || null, page: 1 },
queryParamsHandling:'merge',
});
}
setSort(sort: string): void { this.setFilter('sort', sort); }
goToPage(page: number): void {
this.router.navigate([], {
queryParams: { page },
queryParamsHandling:'merge',
});
}
onSearch(value: string): void {
if (this.searchDebounce) clearTimeout(this.searchDebounce);
this.searchDebounce = setTimeout(() => {
this.setFilter('q', value);
}, 350);
}
}
How It Works
Step 1 — URL as the Single Source of Truth for Filter State
By driving all filter state from URL query parameters, the component’s state is always a pure function of the URL. When the component initialises (or when the URL changes), it reads the parameters, updates the signals, and loads data. This means the browser’s back and forward buttons work correctly, bookmarks preserve filter state, and sharing a URL shares the exact view the user sees.
Step 2 — Navigate to Change, Subscribe to React
Filter changes call router.navigate([], { queryParams: {...}, queryParamsHandling: 'merge' }). This updates the URL without a full page navigation. The component’s route.queryParamMap subscription fires with the new params, signals are updated, and store.load() is called with the new params. This one-way flow (user action → URL change → data load) prevents inconsistencies between displayed filters and loaded data.
Step 3 — Sort Field Whitelist Prevents Injection
Direct use of req.query.sort in a MongoDB sort object would allow an attacker to sort by any field — including sensitive fields like password or __v. By checking the sort field against an explicit whitelist of allowed fields, unknown or sensitive field names are ignored and the default sort is used. The sort direction prefix (-) is handled separately and only affects the sign, not the field selection.
Step 4 — Reset Page to 1 on Filter Change
When a user is on page 5 and changes the status filter, page 5 of the new filtered results may not exist (if there are fewer than 40 filtered tasks). Always reset to page 1 when any filter changes: queryParams: { status: newStatus, page: 1 }. The explicit page: 1 overrides the current page value in the URL even with queryParamsHandling: 'merge'.
Step 5 — Promise.all Parallelises Count and Data Queries
Pagination requires both the current page’s data AND the total document count. Running them sequentially (await find(), then await countDocuments()) adds both query times together. Running them in parallel with Promise.all([find(), countDocuments()]) reduces the endpoint latency to the time of the slower query (usually similar). For large collections, add a dedicated count cache or use MongoDB’s $facet aggregation to get both in one query.
Common Mistakes
Mistake 1 — Storing filter state in component signals without URL sync
❌ Wrong — filter state lost on navigation and back-button:
statusFilter = signal('pending');
// User navigates away, comes back — statusFilter resets to ''
// User shares URL — recipient sees default, not the filtered view
✅ Correct — drive state from URL params:
// All filter changes call router.navigate with updated queryParams
// Component reads initial state from route.queryParamMap
Mistake 2 — Not validating the sort parameter
❌ Wrong — any field can be used for sorting:
const sort = req.query.sort || '-createdAt';
Task.find(filter).sort(sort); // sort=password reveals comparative password info!
✅ Correct — whitelist allowed sort fields:
const ALLOWED = ['createdAt', 'priority', 'dueDate', 'title'];
const field = ALLOWED.includes(raw) ? raw : 'createdAt';
Mistake 3 — Not resetting page when filter changes
❌ Wrong — user on page 10 changes filter, stays on page 10 (may be empty):
setFilter('status', 'completed');
// queryParams: { status: 'completed' } — page stays at 10!
✅ Correct — always reset to page 1 on filter change:
router.navigate([], { queryParams: { status: 'completed', page: 1 }, queryParamsHandling: 'merge' });
Quick Reference
| Task | Code |
|---|---|
| Parse page/limit (Express) | const page = Math.max(1, parseInt(req.query.page) || 1) |
| Skip calculation | const skip = (page - 1) * limit |
| Parallel count + data | await Promise.all([Task.find(...).lean(), Task.countDocuments(...)]) |
| Read filter from URL | this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe(...) |
| Update filter in URL | router.navigate([], { queryParams: { status, page: 1 }, queryParamsHandling: 'merge' }) |
| Change page | router.navigate([], { queryParams: { page: n }, queryParamsHandling: 'merge' }) |
| Clear filter | queryParams: { status: null } (null removes from URL) |
| Whitelist sort fields | const field = ALLOWED.includes(raw) ? raw : 'createdAt' |