Skip to main content

How to Build Pagination in Salesforce Using StandardSetController and Wrapper Classes

Updated Add a comment
💬 In plain words: Pagination breaks massive lists of Salesforce records into smaller, clickable pages (like Page 1, 2, 3). By combining Salesforce's built-in StandardSetController (which handles the math for "Next" and "Previous" pages) with a custom Wrapper Class, you can display these smaller lists while letting users check boxes next to rows to perform bulk actions.

Displaying hundreds or thousands of records on a single page causes slow load times, high memory consumption, and a terrible user experience. Furthermore, standard Salesforce sObjects (like Account) do not have a built-in "isSelected" checkbox field for the UI. To solve both problems simultaneously, we pair the ApexPages.StandardSetController with a custom Apex Wrapper Class.

Pagination in Visualforce page with wrapper class in Salesforce

📌 Key Benefits of This Architecture

  • Automatic Page Offsets: Handles queries up to 10,000 records effortlessly without you having to write complex SOQL OFFSET calculations.
  • Selection State Memory: The wrapper class binds a Salesforce record to a temporary UI boolean flag, so you know exactly which rows the user selected.
  • Optimized Performance: Renders only the active page subset in the DOM, keeping the browser fast and responsive.

⚙️ 1. Apex Custom Controller Implementation

The controller creates a StandardSetController instance using a Database Query Locator. It sets the page size, provides pagination navigation controls, and wraps each returned sObject inside an inner wrapper class for checkbox binding.

Step 1: Create the Controller (AccountPaginationController.cls)
public with sharing class AccountPaginationController {
    public Integer noOfRecords { get; set; }
    public Integer pageSize { get; set; }
    public List<AccountWrapper> accountWrappers { get; set; }

    // 1. Initialize the StandardSetController
    public ApexPages.StandardSetController setCon {
        get {
            if (setCon == null) {
                pageSize = 5; // Display 5 records per page
                setCon = new ApexPages.StandardSetController(
                    Database.getQueryLocator([
                        SELECT Id, Name, AccountNumber, Industry, Phone 
                        FROM Account 
                        ORDER BY Name ASC 
                        WITH USER_MODE
                    ])
                );
                setCon.setPageSize(pageSize);
                noOfRecords = setCon.getResultSize();
            }
            return setCon;
        }
        set;
    }

    // 2. Wrap the records for the CURRENT page
    public List<AccountWrapper> getAccountList() {
        accountWrappers = new List<AccountWrapper>();
        for (Account acc : (List<Account>) setCon.getRecords()) {
            accountWrappers.add(new AccountWrapper(acc));
        }
        return accountWrappers;
    }

    // 3. Process the selected records
    public PageReference processSelected() {
        List<Account> selectedAccounts = new List<Account>();
        if (accountWrappers != null) {
            for (AccountWrapper wrap : accountWrappers) {
                if (wrap.isSelected) {
                    selectedAccounts.add(wrap.accRecord);
                }
            }
        }
        
        ApexPages.addMessage(new ApexPages.Message(
            ApexPages.Severity.CONFIRM, 
            'Selected records count on this page: ' + selectedAccounts.size()
        ));
        return null;
    }

    // 4. Pagination Navigation Methods
    public Boolean hasNext { get { return setCon.getHasNext(); } set; }
    public Boolean hasPrevious { get { return setCon.getHasPrevious(); } set; }
    public Integer pageNumber { get { return setCon.getPageNumber(); } set; }

    public void first() { setCon.first(); }
    public void last() { setCon.last(); }
    public void previous() { setCon.previous(); }
    public void next() { setCon.next(); }

    public PageReference refresh() {
        setCon = null;
        getAccountList();
        setCon.setPageNumber(1);
        return null;
    }

    // 5. Inner Wrapper Class
    public class AccountWrapper {
        public Account accRecord { get; set; }
        public Boolean isSelected { get; set; }

        public AccountWrapper(Account acc) {
            this.accRecord = acc;
            this.isSelected = false; // Checkbox defaults to unchecked
        }
    }
}

🖥️ 2. Visualforce Page Frontend Markup

The page renders the collection inside an <apex:pageBlockTable>. We include an actionStatus to show a loading spinner, pagination links to flip through the data, and checkboxes tied to our wrapper class.

Step 2: Build the Page (AccountPagination.page)
<apex:page controller="AccountPaginationController" sidebar="false">
    <apex:form>
        
        <!-- CSS for Loading Spinner -->
        <style>
            .Processing {
                position: fixed;
                background: rgba(255, 255, 255, 0.7) url('/img/loading32.gif') no-repeat center;
                width: 100%; height: 100%; z-index: 1004; left: 0; top: 0;
            }
            .pagination-panel { margin: 15px 0; font-weight: bold; text-align: center; }
            .pagination-panel a { margin: 0 10px; text-decoration: none; color: #015ba7; }
            .pagination-panel a.disabled { color: #ccc; pointer-events: none; }
        </style>

        <apex:actionStatus id="statusProcessing" startStyleClass="Processing" />

        <apex:outputPanel id="msgPanel">
            <apex:pageMessages />
        </apex:outputPanel>

        <apex:pageBlock title="Accounts - Page #{!pageNumber}" id="mainBlock">
            
            <apex:pageBlockButtons location="top" rendered="{!accountList.size > 0}">
                <apex:commandButton value="Process Selected" action="{!processSelected}" reRender="mainBlock,msgPanel" status="statusProcessing" />
                <apex:commandButton value="Refresh" action="{!refresh}" reRender="mainBlock,msgPanel" status="statusProcessing" />
            </apex:pageBlockButtons>

            <apex:pageMessage severity="info" summary="No records found." rendered="{!accountList.size == 0}" />

            <!-- Data Table -->
            <apex:pageBlockTable value="{!accountList}" var="accWrap" rendered="{!accountList.size > 0}">
                <apex:column width="40px">
                    <apex:facet name="header">Select</apex:facet>
                    <apex:inputCheckbox value="{!accWrap.isSelected}" />
                </apex:column>
                <apex:column value="{!accWrap.accRecord.Name}" />
                <apex:column value="{!accWrap.accRecord.AccountNumber}" />
                <apex:column value="{!accWrap.accRecord.Industry}" />
                <apex:column value="{!accWrap.accRecord.Phone}" />
            </apex:pageBlockTable>

            <!-- Pagination Controls -->
            <apex:outputPanel layout="block" styleClass="pagination-panel" rendered="{!accountList.size > 0}">
                <apex:commandLink action="{!first}" reRender="mainBlock" status="statusProcessing" styleClass="{!IF(hasPrevious, '', 'disabled')}">« First</apex:commandLink>
                <apex:commandLink action="{!previous}" reRender="mainBlock" status="statusProcessing" styleClass="{!IF(hasPrevious, '', 'disabled')}">< Previous</apex:commandLink>
                <apex:commandLink action="{!next}" reRender="mainBlock" status="statusProcessing" styleClass="{!IF(hasNext, '', 'disabled')}">Next ></apex:commandLink>
                <apex:commandLink action="{!last}" reRender="mainBlock" status="statusProcessing" styleClass="{!IF(hasNext, '', 'disabled')}">Last »</apex:commandLink>
            </apex:outputPanel>

        </apex:pageBlock>
    </apex:form>
</apex:page>

🚨 3. Critical Traps & Best Practices

⚠ DEVELOPER TRAP: Cross-Page Selection Loss
In the basic code above, because getAccountList() creates entirely new wrapper instances whenever the page changes, any checkboxes checked on Page 1 will be instantly erased when the user clicks "Next" to go to Page 2. If your business requirement demands persisting selections globally across all pages, you must create a Map<Id, Boolean> inside the controller to remember which specific Record IDs were checked before flipping the page.
🧠 CORE RULE: Always use Database.getQueryLocator() instead of a standard List query. A standard query maxes out at 50,000 rows, but a QueryLocator inside a StandardSetController allows you to paginate up to 10,000 records highly efficiently.
🧭 360 Summary Card
  • Class: ApexPages.StandardSetController
  • Max Query Size: 10,000 Records via Database.getQueryLocator().
  • Key Methods: setPageSize(), next(), previous(), getRecords().
  • Performance Boost: Always specify reRender="mainBlock" and status="statusProcessing" on your pagination links to prevent full, clunky page reloads.

🎤 4. Core Interview Q&A

Q: What happens if a user clicks the "Next" button on the very last page?

🎯 Say this: To prevent runtime errors, you should never allow the user to click "Next" on the final page. You must use the built-in setCon.getHasNext() and setCon.getHasPrevious() boolean methods to selectively disable or hide the pagination links in your Visualforce page (as seen in the code with the disabled CSS class).

Q: I have a massive dataset of 500,000 records. Will StandardSetController work for this?

Answer: No. StandardSetController is strictly capped at 10,000 records when used with Database.getQueryLocator(). If your data exceeds this, you must switch to an API-based pagination approach using SOQL OFFSET (up to 2,000) or cursor-based pagination (querying where ID > lastId), typically surfaced through Lightning Web Components (LWC).

🔗 5. Follow-Up Question

Q: How does this Visualforce pattern translate to modern Lightning Web Components (LWC)?

Answer: In modern LWC architecture, you generally replace this entire pattern with the base <lightning-datatable> component. LWC datatables natively handle row-level checkbox selection out of the box without needing an Apex Wrapper Class. For pagination in LWC, you would fetch smaller chunks of data using an Apex controller that receives an offset/limit parameter, or you would handle pagination purely client-side in JavaScript if the total dataset is small enough.

Was this article helpful?

Have a question or spotted a mistake? Ask here about this article

Found this guide useful? Buy me a coffee (opens in a new tab)

Comments

Questions, corrections and your own experience are all welcome.