Skip to main content

Build a Custom Generic Field History Tracking Component in Salesforce Visualforce

Standard Salesforce Field History tracking is a powerful tool for auditing changes, but standard related lists offer limited flexibility for custom layouts, dynamic embedding, or multi-object audit screens. By creating a generic Visualforce component backed by dynamic Apex and Schema describes, you can render clean, real-time audit trails for any custom object in your org.

In plain words: A generic field history component reads any record ID from the page URL, dynamically queries the corresponding history table (such as Invoice__History), converts API names to friendly field labels, and formats the change audit in a Lightning-style data table.
Custom History Tracking Table in Salesforce

1. Prerequisites: Enabling Field History Tracking

Before querying history records in Apex, verify that history tracking is activated for your target object:

  • Go to Setup > Object Manager > [Your Custom Object].
  • Click Edit and ensure Track Field History is enabled.
  • Click Fields & Relationships > Set History Tracking, and check off up to 20 standard or custom fields you need to monitor.
Developer Trap: If field history tracking is not enabled on the object, the underlying <ObjectName>__History table will not exist in the database, causing dynamic SOQL queries to throw an invalid object type error.

2. JSON Data Wrapper Class (HistoryWrapper.cls)

This helper class provides strongly typed data structures for parsing raw query results and history record attributes:

public with sharing class HistoryWrapper {
    public WrapperClass_attributes attributes;
    public String CreatedById;
    public DateTime CreatedDate;
    public String Field;
    public Boolean IsDeleted;
    public String NewValue;
    public String OldValue;
    public String Id;
    public WrapperClass_CreatedBy CreatedBy;

    public static List<HistoryWrapper> parse(String jsonString) {
        return (List<HistoryWrapper>) JSON.deserialize(jsonString, List<HistoryWrapper>.class);
    }

    public class WrapperClass_attributes {
        public String type;
        public String url;
    }

    public class WrapperClass_CreatedBy {
        public WrapperClass_attributes attributes;
        public String Name;
        public String FirstName;
        public String LastName;
        public String Id;
    }
}

3. Dynamic Apex Controller (HistoryController.cls)

The controller dynamically inspects the current record ID, determines the SObject type, inspects field metadata via Schema.getGlobalDescribe(), queries the history table, and builds formatted audit entries:

public with sharing class HistoryController {
    public List<HistoryEntry> recordsHistory { get; set; }
    public String objectLabel { get; set; }

    public HistoryController() {
        recordsHistory = new List<HistoryEntry>();
        Id parentId = ApexPages.currentPage().getParameters().get('id');

        if (parentId != null) {
            String sObjName = parentId.getSObjectType().getDescribe().getName();
            objectLabel = parentId.getSObjectType().getDescribe().getLabel();
            
            Map<String, Schema.SObjectType> schemaMap = Schema.getGlobalDescribe();
            Schema.SObjectType objSchema = schemaMap.get(sObjName);
            Map<String, Schema.SObjectField> fieldMap = objSchema.getDescribe().fields.getMap();

            // Derive history table name for custom objects
            String historyObjName = sObjName.endsWith('__c') ? sObjName.replace('__c', '__History') : sObjName + 'History';

            if (schemaMap.containsKey(historyObjName)) {
                String query = 'SELECT CreatedBy.Name, CreatedBy.FirstName, CreatedBy.LastName, ' +
                               'CreatedDate, Field, IsDeleted, NewValue, OldValue ' +
                               'FROM ' + String.escapeSingleQuotes(historyObjName) + ' ' +
                               'WHERE ParentId = :parentId ' +
                               'AND Field NOT IN (\'locked\', \'unlocked\') ' +
                               'ORDER BY CreatedDate DESC LIMIT 200';

                List<HistoryWrapper> parsedList = HistoryWrapper.parse(JSON.serialize(Database.query(query)));

                if (parsedList != null && !parsedList.isEmpty()) {
                    for (HistoryWrapper historyItem : parsedList) {
                        if (historyItem.Field == 'created') {
                            recordsHistory.add(new HistoryEntry(
                                historyItem.CreatedBy != null ? historyItem.CreatedBy.Name : 'System',
                                historyItem.CreatedDate,
                                'Created',
                                '',
                                ''
                            ));
                        } else if (fieldMap.containsKey(historyItem.Field)) {
                            String fieldLabel = fieldMap.get(historyItem.Field).getDescribe().getLabel();
                            recordsHistory.add(new HistoryEntry(
                                historyItem.CreatedBy != null ? historyItem.CreatedBy.Name : 'System',
                                historyItem.CreatedDate,
                                fieldLabel,
                                String.valueOf(historyItem.NewValue),
                                String.valueOf(historyItem.OldValue)
                            ));
                        }
                    }
                }
            }
        }
    }

    // Inner UI Wrapper Model
    public class HistoryEntry {
        public String userName { get; set; }
        public String dateValue { get; set; }
        public String fieldLabel { get; set; }
        public String newValue { get; set; }
        public String oldValue { get; set; }

        public HistoryEntry(String user, DateTime dt, String field, String nVal, String oVal) {
            this.userName = user;
            this.dateValue = dt != null ? dt.format() : '';
            this.fieldLabel = field;
            this.newValue = (nVal != null && nVal != 'null') ? nVal : '';
            this.oldValue = (oVal != null && oVal != 'null') ? oVal : '';
        }
    }
}
Step-by-Step Execution Lifecycle:
  • Step 1: Apex parses the id parameter from the page URL and determines the base SObject type.
  • Step 2: Schema describe fetches field API names and their human-readable labels.
  • Step 3: Dynamic SOQL retrieves raw history rows, converting API names into localized field labels for display.

4. Visualforce Component Markup (CustomCodeHistory.component)

Create a reusable Visualforce component styled with the Salesforce Lightning Design System (SLDS):

<apex:component controller="HistoryController">
    <div class="slds-scope">
        <div class="slds-section slds-is-open">
            <h3 class="slds-section__title slds-theme_shade">
                <span class="slds-truncate slds-p-horizontal_small" title="Field History">
                    {!objectLabel} Field History
                </span>
            </h3>
            <div class="slds-section__content">
                <apex:outputPanel rendered="{!recordsHistory.size > 0}" layout="none">
                    <table class="slds-table slds-table_cell-buffer slds-table_bordered slds-table_striped">
                        <thead>
                            <tr class="slds-line-height_reset">
                                <th scope="col"><div class="slds-truncate" title="Modified By">Modified By</div></th>
                                <th scope="col"><div class="slds-truncate" title="Modified Date">Modified Date</div></th>
                                <th scope="col"><div class="slds-truncate" title="Field">Field</div></th>
                                <th scope="col"><div class="slds-truncate" title="New Value">New Value</div></th>
                                <th scope="col"><div class="slds-truncate" title="Old Value">Old Value</div></th>
                            </tr>
                        </thead>
                        <tbody>
                            <apex:repeat value="{!recordsHistory}" var="item">
                                <tr class="slds-hint-parent">
                                    <td><div class="slds-truncate">{!item.userName}</div></td>
                                    <td><div class="slds-truncate">{!item.dateValue}</div></td>
                                    <td><div class="slds-truncate">{!item.fieldLabel}</div></td>
                                    <td><div class="slds-truncate">{!item.newValue}</div></td>
                                    <td><div class="slds-truncate">{!item.oldValue}</div></td>
                                </tr>
                            </apex:repeat>
                        </tbody>
                    </table>
                </apex:outputPanel>

                <apex:outputPanel rendered="{!recordsHistory.size == 0}" layout="none">
                    <div class="slds-notify slds-notify_alert" role="alert">
                        <h2>No history tracking records found for this entry.</h2>
                    </div>
                </apex:outputPanel>
            </div>
        </div>
    </div>
</apex:component>

5. Embedding in a Visualforce Page

To display the history tracking table, reference your component inside a hosting Visualforce page:

<apex:page lightningStylesheets="true">
    <apex:slds />
    <!-- Renders the custom generic history component -->
    <c:CustomCodeHistory />
</apex:page>

6. Architecture & Platform Considerations

Field History Audit Matrix:
  • Tracking Limits: Standard history tracks up to 20 fields per custom or standard object (expandable to 60 with Field Audit Trail).
  • Retention Window: Standard field history data is retained for up to 18 months via the UI and 24 months via API.
  • Performance Safeguards: Always apply LIMIT clauses and bind parameters (:parentId) to protect against SOQL injection and heap exhaustion.
Core Rule: Use Schema describes to map raw field API names to human-readable labels so that users always see familiar business terms instead of developer API strings.

Summary

Building a generic field history component eliminates the need to write separate history pages for every object in your org. By combining dynamic SOQL with Schema metadata describes and SLDS data tables, you achieve a unified audit trail UI that automatically adapts to any record ID passed to it.