StandardSetController.
Displaying large datasets in Salesforce without pagination degrades page responsiveness and risks hitting platform limits. Querying thousands of records at once blows past the 50,000 SOQL row retrieval limit, inflates Visualforce View State beyond 170 KB, and creates a frustrating user experience. Implementing structured pagination in Apex and Visualforce gives users fast, predictable navigation while keeping platform resource consumption minimal.
1. Understanding Pagination Architecture in Salesforce
Salesforce developers typically implement pagination using one of two core server-side design patterns:
- Custom SOQL Limit & Offset: The Apex controller calculates the exact record offset based on the current page number (
OFFSET = (pageNum - 1) * pageSize) and queries only the necessary slice of data. - StandardSetController: Salesforce's native list controller handles database query chunking, cursor caching, and pagination controls (
next(),previous(),getHasNext()) automatically with minimal custom logic.
- Standard Approach:
ApexPages.StandardSetController(supports up to 10,000 records out of the box). - Custom SOQL Pattern:
LIMIT :pageSize OFFSET :offsetVal(hard SOQL offset limit is 2,000 rows). - View State Optimization: Mark table list collections as
transientor rely on partial AJAX re-rendering (reRender). - Modern UI Standard: Lightning Web Components (
<lightning-datatable>) using client-side slicing or wire adapters.
2. Implementing Custom Pagination with Apex & Visualforce
The controller manages page calculation, queries record counts, and applies dynamic
LIMIT and OFFSET parameters.
public with sharing class DataTablePaginationController {
public List<Account> accounts { get; set; }
public Integer pageSize { get; set; }
public Integer totalRecords { get; set; }
public Integer pageNum { get; set; }
public Integer totalPages { get; set; }
public DataTablePaginationController() {
pageSize = 10; // Records per page
pageNum = 1; // Starting page
fetchData();
}
public void fetchData() {
// 1. Get total record count for calculation
totalRecords = [SELECT COUNT() FROM Account WITH USER_MODE];
// 2. Calculate total pages
if (totalRecords > 0) {
totalPages = (Integer) Math.ceil((Decimal) totalRecords / pageSize);
} else {
totalPages = 1;
}
// 3. Compute query offset
Integer offsetVal = (pageNum - 1) * pageSize;
// 4. Retrieve page records safely using SOQL LIMIT and OFFSET
accounts = [
SELECT Id, Name, Industry, Type, AnnualRevenue
FROM Account
WITH USER_MODE
ORDER BY Name ASC
LIMIT :pageSize
OFFSET :offsetVal
];
}
public void nextPage() {
if (pageNum < totalPages) {
pageNum++;
fetchData();
}
}
public void previousPage() {
if (pageNum > 1) {
pageNum--;
fetchData();
}
}
public void firstPage() {
pageNum = 1;
fetchData();
}
public void lastPage() {
pageNum = totalPages;
fetchData();
}
public Boolean getHasNext() {
return pageNum < totalPages;
}
public Boolean getHasPrevious() {
return pageNum > 1;
}
}
Use
reRender on the pagination buttons to update only the table block without a full browser reload.
<apex:page controller="DataTablePaginationController" lightningStylesheets="true">
<apex:sectionHeader title="Account Directory" subtitle="Paginated Data Table" />
<apex:form id="tableForm">
<apex:pageBlock id="dataBlock" title="All Accounts (Total: {!totalRecords})">
<!-- Account Table -->
<apex:pageBlockTable value="{!accounts}" var="acc">
<apex:column value="{!acc.Name}"/>
<apex:column value="{!acc.Industry}"/>
<apex:column value="{!acc.Type}"/>
<apex:column value="{!acc.AnnualRevenue}"/>
</apex:pageBlockTable>
<!-- Pagination Controls Bar -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 14px;">
<div>
<span style="font-weight: bold; color: #1f4e79;">
Page {!pageNum} of {!totalPages}
</span>
</div>
<div>
<apex:commandButton value="« First" action="{!firstPage}"
disabled="{!!hasPrevious}" reRender="dataBlock" styleClass="slds-button slds-button_neutral" />
<apex:commandButton value="‹ Previous" action="{!previousPage}"
disabled="{!!hasPrevious}" reRender="dataBlock" styleClass="slds-button slds-button_neutral" />
<apex:commandButton value="Next ›" action="{!nextPage}"
disabled="{!!hasNext}" reRender="dataBlock" styleClass="slds-button slds-button_neutral" />
<apex:commandButton value="Last »" action="{!lastPage}"
disabled="{!!hasNext}" reRender="dataBlock" styleClass="slds-button slds-button_neutral" />
</div>
</div>
</apex:pageBlock>
</apex:form>
</apex:page>
3. StandardSetController: The Native Alternative
For standard pagination tasks, using StandardSetController significantly reduces Apex boilerplate code:
public with sharing class StandardSetPaginationController {
public ApexPages.StandardSetController setCon {
get {
if (setCon == null) {
setCon = new ApexPages.StandardSetController(
Database.getQueryLocator([SELECT Id, Name, Industry FROM Account WITH USER_MODE ORDER BY Name])
);
setCon.setPageSize(10);
}
return setCon;
}
set;
}
public List<Account> getAccounts() {
return (List<Account>) setCon.getRecords();
}
}
4. Common Traps & Platform Limit Pitfalls
SOQL enforces a maximum
OFFSET of 2,000 rows. If a user navigates to page 201 with a page size of 10, the offset becomes 2,010, which throws a fatal runtime exception: NUMBER_OUTSIDE_VALID_RANGE_FOR_TYPE. For datasets exceeding 2,000 rows, use StandardSetController or implement keyset pagination (querying based on WHERE Id > :lastSeenId).
- Fix the LIMIT / OFFSET Syntax Bug: Always write the SOQL clause with limit first, then offset:
LIMIT :pageSize OFFSET :offsetVal. Inverting variables or applying offset to limit causes broken queries. - Always Use Partial Re-rendering: Ensure pagination action buttons define a valid
reRendertarget ID (likereRender="dataBlock") to prevent full-page refreshes. - Enforce User Mode Security: Always append
WITH USER_MODEto your SOQL queries to enforce object-level and field-level security automatically.
Summary
Data table pagination is a fundamental requirement for responsive Salesforce enterprise applications. By combining calculated LIMIT and OFFSET SOQL queries with Visualforce partial re-rendering—or by adopting StandardSetController—developers can deliver clean, high-performance table navigation while keeping page execution well within platform governor limits.