Skip to content
March 17, 2026·9 min read

Migrating from Visualforce to LWC: A Practical Guide

Migrate Visualforce pages to Lightning Web Components step by step — mapping VF concepts to LWC, data access patterns, and handling the edge cases that trip up migrations.

Back

Visualforce isn't going away overnight, but Lightning Web Components are the present and future of Salesforce UI development. Migrating incrementally — one component at a time — is the realistic approach for most orgs.


When to Migrate (and When Not To)

Good candidates for migration:

  • High-traffic pages that would benefit from LWC performance
  • Pages users complain are slow (VF view state is a common culprit)
  • New features you're building alongside existing VF pages

Keep as Visualforce:

  • PDF generation (VF renders PDFs natively — LWC doesn't)
  • Email templates that use <messaging:emailTemplate>
  • Complex inline editing grids that would require significant rework
  • Pages that work and nobody touches — don't migrate for the sake of it

Concept Mapping

| Visualforce | LWC Equivalent | |-------------|---------------| | <apex:page> | Component html + js files | | <apex:form> + <apex:commandButton> | <form> + Server Action or @wire | | <apex:inputField> | <lightning-input-field> | | <apex:outputField> | <lightning-output-field> | | <apex:pageBlock> | <lightning-card> | | <apex:dataTable> | <lightning-datatable> | | <apex:repeat> | for:each directive | | Standard controller | lightning/uiRecordApi wire adapters | | Custom controller | Apex class called with @wire or callApex | | $User.Id, $Label | @salesforce/user/Id, @salesforce/label/c.My_Label |


Simple Field Display Migration

Visualforce:

<apex:page standardController="Account">
    <apex:pageBlock title="Account Details">
        <apex:pageBlockSection>
            <apex:outputField value="{!Account.Name}"/>
            <apex:outputField value="{!Account.Phone}"/>
            <apex:outputField value="{!Account.Industry}"/>
        </apex:pageBlockSection>
    </apex:pageBlock>
</apex:page>

LWC equivalent:

<!-- accountDetails.html -->
<template>
    <lightning-card title="Account Details">
        <div class="slds-p-around_medium">
            <lightning-record-view-form record-id={recordId} object-api-name="Account">
                <lightning-output-field field-name="Name"></lightning-output-field>
                <lightning-output-field field-name="Phone"></lightning-output-field>
                <lightning-output-field field-name="Industry"></lightning-output-field>
            </lightning-record-view-form>
        </div>
    </lightning-card>
</template>
// accountDetails.js
import { LightningElement, api } from 'lwc';
 
export default class AccountDetails extends LightningElement {
    @api recordId;
}

Form with Custom Logic Migration

Visualforce controller (simplified):

public class AccountFormController {
    public Account acc { get; set; }
 
    public AccountFormController() {
        acc = new Account();
    }
 
    public PageReference save() {
        if (acc.Name == null) {
            ApexPages.addMessage(new ApexPages.Message(
                ApexPages.Severity.ERROR, 'Name is required'));
            return null;
        }
        insert acc;
        return new PageReference('/lightning/r/Account/' + acc.Id + '/view');
    }
}

LWC with Server Action:

// accountForm.js
import { LightningElement } from 'lwc';
import { NavigationMixin } from 'lightning/navigation';
import createAccount from '@salesforce/apex/AccountFormController.createAccount';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
 
export default class AccountForm extends NavigationMixin(LightningElement) {
    name = '';
    phone = '';
 
    handleNameChange(event) {
        this.name = event.target.value;
    }
 
    async handleSave() {
        if (!this.name) {
            this.dispatchEvent(new ShowToastEvent({
                title: 'Validation Error',
                message: 'Name is required',
                variant: 'error',
            }));
            return;
        }
 
        try {
            const accountId = await createAccount({ name: this.name, phone: this.phone });
            this[NavigationMixin.Navigate]({
                type: 'standard__recordPage',
                attributes: { recordId: accountId, actionName: 'view' },
            });
        } catch (error) {
            this.dispatchEvent(new ShowToastEvent({
                title: 'Error',
                message: error.body?.message ?? 'Unknown error',
                variant: 'error',
            }));
        }
    }
}

Replacing <apex:repeat> and Dynamic Tables

Visualforce:

<apex:repeat value="{!contacts}" var="c">
    <div>{!c.Name} — {!c.Email}</div>
</apex:repeat>

LWC:

<template for:each={contacts} for:item="contact">
    <div key={contact.Id}>
        {contact.Name} — {contact.Email}
    </div>
</template>

For editable tables, use <lightning-datatable>:

columns = [
    { label: 'Name', fieldName: 'Name', type: 'text', editable: true },
    { label: 'Email', fieldName: 'Email', type: 'email' },
    { label: 'Phone', fieldName: 'Phone', type: 'phone' },
];
<lightning-datatable
    key-field="Id"
    data={contacts}
    columns={columns}
    oncellchange={handleCellChange}
    onsave={handleSave}
    draft-values={draftValues}>
</lightning-datatable>

Migration Strategy

  1. Identify page complexity — simple (< 1 day), medium (1–3 days), complex (1+ week)
  2. Create the LWC alongside the VF page — don't delete VF until the LWC is validated
  3. Use a feature flag — custom setting LWC_Enabled__c to switch between VF and LWC per user
  4. Validate with users first — deploy LWC to a subset of users before full rollout
  5. Remove the VF page — only after 2–4 weeks of successful LWC usage

Common Pitfalls

  • View state → no view state: LWC has no view state concept — use @wire for data, @track (or regular properties) for local state
  • Page redirects: VF uses PageReference — LWC uses NavigationMixin
  • apex:messages: VF error messages use ApexPages.addMessage — LWC uses ShowToastEvent or in-page error rendering
  • Inline CSS in VF: LWC enforces Lightning Design System — avoid raw CSS in component scope, use SLDS utility classes

Resources

Aïcha Imène DAHOUMANE
About the author
Aïcha Imène DAHOUMANE
Salesforce Administrator & Developer · Consultant

Salesforce developer with a background in IT infrastructure — building reliable, maintainable solutions with Apex, Flows, LWC, and CI/CD.

ShareShare on LinkedIn