Skip to main content

How to Build Reusable Pagination in Lightning Web Components (LWC) with Apex

Loading large datasets into a single view slows down page render speeds, clutters the UI, and risks exhausting browser memory. Implementing clean, responsive pagination inside your Lightning Web Components (LWC) delivers a crisp user experience while keeping data retrieval well within Salesforce platform limits.

In plain words: LWC pagination splits large record lists into bite-sized pages. Instead of fetching thousands of records at once, the component requests only the current page slice from Apex or slices client-side memory on demand.

1. Apex Controller with Server-Side Paging

The backend Apex controller queries records using dynamic LIMIT and OFFSET parameters, wrapping the records alongside the total count into a single response object:

public with sharing class ItemPaginationController {

    public class PagedResult {
        @AuraEnabled public Integer totalCount { get; set; }
        @AuraEnabled public List<Account> records { get; set; }
    }

    @AuraEnabled(cacheable=true)
    public static PagedResult getItems(Integer pageNumber, Integer pageSize) {
        PagedResult result = new PagedResult();
        
        // Calculate offset safely
        Integer offsetRows = (pageNumber - 1) * pageSize;
        
        result.totalCount = [SELECT COUNT() FROM Account WITH USER_MODE];
        result.records = [
            SELECT Id, Name, Industry, Type, CreatedDate 
            FROM Account 
            WITH USER_MODE 
            ORDER BY Name ASC 
            LIMIT :pageSize 
            OFFSET :offsetRows
        ];
        
        return result;
    }
}

2. LWC JavaScript Controller (pagination.js)

The JavaScript controller uses the reactive @wire service. Whenever the reactive parameter '$currentPage' updates, the wire adapter automatically retrieves the new page slice from the server:

import { LightningElement, wire } from 'lwc';
import getItems from '@salesforce/apex/ItemPaginationController.getItems';

const PAGE_SIZE = 10;

export default class Pagination extends LightningElement {
    displayItems = [];
    currentPage = 1;
    totalPages = 1;
    totalRecords = 0;
    pageNumbers = [];
    error;

    @wire(getItems, { pageNumber: '$currentPage', pageSize: PAGE_SIZE })
    wiredItems({ error, data }) {
        if (data) {
            this.displayItems = data.records;
            this.totalRecords = data.totalCount;
            this.totalPages = Math.ceil(data.totalCount / PAGE_SIZE) || 1;
            
            // Build dynamic numeric button array
            this.pageNumbers = Array.from({ length: this.totalPages }, (_, i) => ({
                number: i + 1,
                variant: (i + 1 === this.currentPage) ? 'brand' : 'neutral'
            }));
            this.error = undefined;
        } else if (error) {
            this.error = error;
            this.displayItems = [];
        }
    }

    get isFirstPage() {
        return this.currentPage <= 1;
    }

    get isLastPage() {
        return this.currentPage >= this.totalPages;
    }

    handlePrevious() {
        if (this.currentPage > 1) {
            this.currentPage--;
        }
    }

    handleNext() {
        if (this.currentPage < this.totalPages) {
            this.currentPage++;
        }
    }

    handlePageSelect(event) {
        this.currentPage = parseInt(event.target.value, 10);
    }
}

3. HTML Template (pagination.html)

The template displays items using modern Salesforce Lightning Design System (SLDS) cards, list items, and lightning button controls:

<template>
    <lightning-card title="Account Directory" icon-name="standard:account">
        <div class="slds-p-around_medium">
            
            <!-- Record List -->
            <template lwc:if={displayItems.length}>
                <ul class="slds-has-dividers_bottom-space">
                    <template for:each={displayItems} for:item="item">
                        <li key={item.Id} class="slds-item slds-p-vertical_x-small">
                            <div class="slds-grid slds-grid_align-spread">
                                <span class="slds-text-heading_small slds-truncate">{item.Name}</span>
                                <span class="slds-badge">{item.Industry}</span>
                            </div>
                        </li>
                    </template>
                </ul>
            </template>

            <template lwc:elseif={error}>
                <div class="slds-notify slds-notify_alert slds-theme_alert-texture slds-theme_error">
                    <h2>Error loading records. Please check permissions.</h2>
                </div>
            </template>

            <!-- Pagination Controls Bar -->
            <div class="slds-grid slds-grid_align-center slds-m-top_medium slds-grid_vertical-align-center">
                
                <!-- Previous Button -->
                <lightning-button-icon
                    icon-name="utility:chevronleft"
                    variant="border-filled"
                    alternative-text="Previous Page"
                    title="Previous"
                    disabled={isFirstPage}
                    onclick={handlePrevious}
                    class="slds-m-right_x-small">
                </lightning-button-icon>

                <!-- Page Number Buttons -->
                <template for:each={pageNumbers} for:item="page">
                    <lightning-button
                        key={page.number}
                        label={page.number}
                        value={page.number}
                        variant={page.variant}
                        onclick={handlePageSelect}
                        class="slds-m-horizontal_xx-small">
                    </lightning-button>
                </template>

                <!-- Next Button -->
                <lightning-button-icon
                    icon-name="utility:chevronright"
                    variant="border-filled"
                    alternative-text="Next Page"
                    title="Next"
                    disabled={isLastPage}
                    onclick={handleNext}
                    class="slds-m-left_x-small">
                </lightning-button-icon>

            </div>
            
            <!-- Status Indicator -->
            <p class="slds-text-align_center slds-text-color_weak slds-m-top_x-small slds-text-body_small">
                Page {currentPage} of {totalPages} ({totalRecords} Total Records)
            </p>

        </div>
    </lightning-card>
</template>
Step-by-Step Implementation Flow:
  • Step 1: Expose an @AuraEnabled(cacheable=true) method in Apex returning a wrapper with items and total record count.
  • Step 2: Declare a reactive currentPage property in LWC and bind it to the wire adapter parameter.
  • Step 3: Calculate total pages using Math.ceil(totalCount / pageSize) and generate an array of button objects.
  • Step 4: Update currentPage via button click handlers; the wire service handles data refreshing automatically.

4. Critical Architecture Trap: SOQL Offset Limit

Developer Trap: The maximum allowed SOQL OFFSET in Salesforce is 2,000 rows. If your dataset exceeds 2,000 records, standard server-side offset pagination will fail. For large datasets, use Keyset Pagination (querying with WHERE Id > :lastSeenId ORDER BY Id ASC LIMIT :pageSize) or client-side caching.

5. Pagination Strategy Comparison

Architecture Decision Matrix:
  • Client-Side Slicing: Retrieve all records once (under 1,000 rows) and slice with data.slice(start, end) in JavaScript. Offers instant page switches with zero extra server calls.
  • Server-Side Offset: Best for medium tables (100 to 2,000 rows) where initial load payload must stay small.
  • Keyset Paging / Infinite Scroll: The enterprise standard for large datasets (> 2,000 rows) and mobile-optimized feeds using lightning-datatable.
Core Takeaway: Combine reactive wire adapters with @AuraEnabled(cacheable=true) to get automatic client-side caching and responsive page navigation out of the box.

Summary

Adding pagination to your Lightning Web Components transforms dense data tables into fast, scannable user interfaces. By using the reactive wire service, SLDS styling tokens, and clean button controls, you ensure your custom components stay performant, accessible, and easy to maintain.