Pagination, Filtering, and Sorting — Full-Stack Query Pattern

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
Note: Store pagination and filter state in the URL query parameters — not in component signals alone. URL-based state means the user can bookmark a filtered view, share it with a colleague, and return to it after navigating away. When the component initialises, it reads the current query params from 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.
Tip: Reset the page to 1 whenever any filter or sort changes. A user on page 5 filtering for “high priority” would otherwise see page 5 of the filtered results — which may be empty. The correct UX is to always start at page 1 when the result set changes. In the Angular navigation call: when status or sort changes, set queryParams: { page: 1, status: newStatus } — the explicit page: 1 overrides the current page in queryParamsHandling: 'merge'.
Warning: Always validate and sanitise query parameters on the Express side. The 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'

🧠 Test Yourself

A user is viewing page 3 of the task list filtered by status=pending. They change the sort to “Due Date”. What should happen to the page parameter?