Skip to main content

Salesforce Pagination with StandardSetController & Wrapper Class: Complete Guide

In plain words: Pagination breaks large tables of records into smaller, manageable pages. Using Salesforce's built-in StandardSetController together with a custom Wrapper Class lets you display a subset of records per page while allowing users to select rows with checkboxes for mass actions.

Displaying hundreds or thousands of records on a single page causes slow load times, high memory consumption, and poor user experiences. Combining ApexPages.StandardSetController with an Apex wrapper class provides a robust solution for table pagination with row-level selection capabilities.

Pagination in Visualforce page with wrapper class in Salesforce

1. Apex Controller Implementation

The controller creates a StandardSetController instance via a Database Query Locator, configures the page size, provides pagination controls (next(), previous(), first(), last()), and wraps each sObject inside an inner wrapper class for checkbox binding.

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

    public ApexPages.StandardSetController setCon {
        get {
            if (setCon == null) {
                pageSize = 5;
                setCon = new ApexPages.StandardSetController(
                    Database.getQueryLocator([
                        SELECT Id, LineNumber, PricebookEntry.Product2.Name, Product2Id, Quantity, UnitPrice, QuoteId 
                        FROM QuoteLineItem 
                        WITH USER_MODE
                    ])
                );
                setCon.setPageSize(pageSize);
                noOfRecords = setCon.getResultSize();
            }
            return setCon;
        }
        set;
    }

    public List<SelectedWrapper> getLineItems() {
        categories = new List<SelectedWrapper>();
        for (QuoteLineItem item : (List<QuoteLineItem>) setCon.getRecords()) {
            categories.add(new SelectedWrapper(item));
        }
        return categories;
    }

    public PageReference executeCustomAction() {
        List<QuoteLineItem> selectedItems = new List<QuoteLineItem>();
        if (categories != null) {
            for (SelectedWrapper wrap : categories) {
                if (wrap.isChecked) {
                    selectedItems.add(wrap.itemRecord);
                }
            }
        }
        ApexPages.addMessage(new ApexPages.Message(
            ApexPages.Severity.CONFIRM, 
            'Selected records count on current page: ' + selectedItems.size()
        ));
        return null;
    }

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

    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(); }

    // Inner Wrapper Class for Checkbox Selection
    public class SelectedWrapper {
        public Boolean isChecked { get; set; }
        public QuoteLineItem itemRecord { get; set; }

        public SelectedWrapper(QuoteLineItem item) {
            this.itemRecord = item;
            this.isChecked = false;
        }
    }
}

2. Visualforce Page Implementation

The page renders the collection inside an <apex:pageBlockTable> with action status loaders, pagination links, and real-time checkbox selection.

Step 2: Build the Page (QuoteLineItemPagination.page)
<apex:page controller="QuoteLineItemPaginationController" sidebar="false">
    <apex:form>
        <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: 10px 0;
                font-weight: bold;
            }
            .pagination-panel a {
                margin-right: 15px;
                text-decoration: none;
            }
        </style>

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

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

        <apex:pageBlock title="{!$ObjectType.QuoteLineItem.LabelPlural} - Page #{!pageNumber}" id="mainBlock">
            <apex:pageBlockButtons location="top" rendered="{!lineItems.size > 0}">
                <apex:commandButton value="Process Selected" action="{!executeCustomAction}" 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="{!lineItems.size == 0}" />

            <apex:pageBlockTable value="{!lineItems}" var="row" id="recordTable" rendered="{!lineItems.size > 0}">
                <apex:column width="40px">
                    <apex:facet name="header">Select</apex:facet>
                    <apex:inputCheckbox value="{!row.isChecked}" />
                </apex:column>
                <apex:column value="{!row.itemRecord.LineNumber}" headerValue="Line #" />
                <apex:column value="{!row.itemRecord.PricebookEntry.Product2.Name}" headerValue="Product Name" />
                <apex:column value="{!row.itemRecord.Quantity}" headerValue="Quantity" />
                <apex:column value="{!row.itemRecord.UnitPrice}" headerValue="Sales Price" />
            </apex:pageBlockTable>

            <apex:outputPanel layout="block" styleClass="pagination-panel" rendered="{!lineItems.size > 0}">
                <apex:panelGrid columns="4">
                    <apex:commandLink action="{!first}" reRender="mainBlock" status="statusProcessing">First</apex:commandLink>
                    <apex:commandLink action="{!previous}" rendered="{!hasPrevious}" reRender="mainBlock" status="statusProcessing">Previous</apex:commandLink>
                    <apex:commandLink action="{!next}" rendered="{!hasNext}" reRender="mainBlock" status="statusProcessing">Next</apex:commandLink>
                    <apex:commandLink action="{!last}" reRender="mainBlock" status="statusProcessing">Last</apex:commandLink>
                </apex:panelGrid>
            </apex:outputPanel>
        </apex:pageBlock>
    </apex:form>
</apex:page>
360 Pagination Architecture Card:
  • Query Locator Capacity: Database.getQueryLocator() supports up to 10,000 records in standard controllers.
  • Navigation API: Built-in getHasNext(), getHasPrevious(), first(), and last() simplify UI logic.
  • Wrapper Pattern: Pairs raw database sObjects with transient UI state attributes (like isChecked).
  • User Mode Security: Query enforces field and object security natively via WITH USER_MODE.

3. Critical Traps & Best Practices

Developer Trap: Cross-Page Selection Loss
Because getLineItems() instantiates new wrapper instances whenever pagination changes, checkboxes checked on Page 1 are reset when navigating to Page 2. If cross-page selection persistence is required, maintain a state Map of Map<Id, Boolean> inside the controller to preserve selected IDs across page transitions.
Core Rule: Use Database.getQueryLocator inside StandardSetController to paginate up to 10,000 records without hitting the standard 50,000 SOQL row heap limits in Visualforce controllers.
  • Partial DOM Rerendering: Always specify reRender="mainBlock" and status="statusProcessing" on pagination links to prevent full page reloads.
  • Maintain Secure Class Declarations: Declare controllers as with sharing to enforce org-wide sharing rules.
  • Modern Alternative: For modern applications, implement pagination declaratively or via Lightning Web Components using the base <lightning-datatable> with client-side or server-side slicing.

Summary

Using StandardSetController with a custom wrapper class provides an efficient, standardized approach to paginating records in Salesforce Visualforce pages. This architecture keeps heap sizes lean, improves rendering performance, and delivers seamless record selection workflows for end users.