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
- Identify page complexity — simple (< 1 day), medium (1–3 days), complex (1+ week)
- Create the LWC alongside the VF page — don't delete VF until the LWC is validated
- Use a feature flag — custom setting
LWC_Enabled__cto switch between VF and LWC per user - Validate with users first — deploy LWC to a subset of users before full rollout
- 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
@wirefor data,@track(or regular properties) for local state - Page redirects: VF uses
PageReference— LWC usesNavigationMixin apex:messages: VF error messages useApexPages.addMessage— LWC usesShowToastEventor in-page error rendering- Inline CSS in VF: LWC enforces Lightning Design System — avoid raw CSS in component scope, use SLDS utility classes