Skip to main content

How to Build Pagination in Salesforce Using StandardSetController and Wrapper Classes

When displaying large datasets in Salesforce, showing hundreds of records on a single page degrades browser performance and overwhelms users. Pairing Apex's native StandardSetController with a custom Wrapper Class allows you to build clean, paginated tables with interactive elements like checkboxes—without running into governor limits.

In plain words: The StandardSetController manages chunking large record sets into manageable pages (e.g., 10 or 20 records per view), while a wrapper class pairs each Salesforce record with temporary UI controls—such as a "Selected" checkbox—so users can perform bulk actions across pages.

Why Combine StandardSetController and Wrapper Classes?

Standard list controllers natively provide built-in methods like next(), previous(), first(), and last(). However, raw sObjects lack UI-only variables. A wrapper class acts as a data container that binds a Salesforce record (Account, Contact, etc.) to custom boolean flags.

  • Automatic Page Offsets: Handles queries up to 10,000 records without custom offset SOQL calculations.
  • Selection State Memory: Keeps track of records selected by the user as they navigate back and forth between pages.
  • Optimized Performance: Renders only the active page subset in the DOM for fast loading.

Step 1: Apex Custom Controller Implementation

Below is the complete Apex controller demonstrating how to instantiate ApexPages.StandardSetController and wrap the record list for display.

Real-Life Example: Account Pagination Controller
This Apex controller queries accounts, sets the page size to 5 records, and converts each page's record set into wrapped objects.
public with sharing class AccountPaginationWrapperController {

    // StandardSetController declaration
    public ApexPages.StandardSetController setCon {
        get {
            if(setCon == null) {
                setCon = new ApexPages.StandardSetController(Database.getQueryLocator(
                    [SELECT Id, Name, AccountNumber, Industry, Phone FROM Account ORDER BY Name ASC LIMIT 10000]
                ));
                // Set the number of records per page
                setCon.setPageSize(5);
            }
            return setCon;
        }
        set;
    }

    // Initialize list of wrapper objects for the current page
    public List<AccountWrapper> getAccountList() {
        List<AccountWrapper> categories = new List<AccountWrapper>();
        for (Account acc : (List<Account>) setCon.getRecords()) {
            categories.add(new AccountWrapper(acc));
        }
        return categories;
    }

    // Process selected records across pages
    public void processSelected() {
        List<Account> selectedAccounts = new List<Account>();
        for (AccountWrapper wrap : getAccountList()) {
            if (wrap.isSelected == true) {
                selectedAccounts.add(wrap.accRecord);
            }
        }
        // Perform actions on selected records (e.g., Update, Export, Delete)
    }

    // 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;
        }
    }
}

Step 2: Visualforce Page Frontend Markup

Render the wrapped data inside a data table along with pagination navigation controls (First, Previous, Next, Last).

<apex:page controller="AccountPaginationWrapperController">
    <apex:form >
        <apex:pageBlock title="Paginated Accounts with Selection">
            
            <apex:pageBlockButtons location="top">
                <apex:commandButton value="Process Selected" action="{!processSelected}" reRender="table" />
            </apex:pageBlockButtons>

            <!-- Account Table -->
            <apex:pageBlockTable value="{!accountList}" var="accWrap" id="table">
                <apex:column >
                    <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:panelGrid columns="4" style="margin-top:10px;">
                <apex:commandButton value="First" action="{!setCon.first}" disabled="{!NOT(setCon.hasPrevious)}" reRender="table,controls" />
                <apex:commandButton value="Previous" action="{!setCon.previous}" disabled="{!NOT(setCon.hasPrevious)}" reRender="table,controls" />
                <apex:commandButton value="Next" action="{!setCon.next}" disabled="{!NOT(setCon.hasNext)}" reRender="table,controls" />
                <apex:commandButton value="Last" action="{!setCon.last}" disabled="{!NOT(setCon.hasNext)}" reRender="table,controls" />
            </apex:panelGrid>

        </apex:pageBlock>
    </apex:form>
</apex:page>
Common Developer Pitfalls:
  • State Loss on Page Navigation: Re-instantiating wrappers inside getter methods without preserving a master selection map will clear checkbox states when users navigate between pages. To persist selections globally across pages, store selected record IDs in a controller-level Map<Id, Boolean>.
  • Query Governor Limits: Passing standard list queries without Database.getQueryLocator() caps queries at 50,000 records. Using getQueryLocator extends the query cap up to 10,000 records specifically suited for set controller pagination.
  • Modern Architecture Migration: If you are building new components in Lightning Experience, prefer Lightning Web Components (LWC) with client-side pagination or wire adapters over legacy Visualforce pages.
Always disable pagination buttons using setCon.hasPrevious and setCon.hasNext expressions to prevent end-of-list runtime errors.
360 Summary Card
  • Class: ApexPages.StandardSetController
  • Max Query Size: 10,000 Records via Database.getQueryLocator()
  • Key Methods: setPageSize(), next(), previous(), getRecords()
  • Primary Use Case: Bulk record selection and paginated data display in custom interfaces.