In enterprise Salesforce applications, users rarely want to search just one table at a time. They want to type a company name and instantly see the Account, the related Contacts, and any open Deals. While SOQL is great for fetching specific fields from a single object hierarchy, it is slow and inefficient for full-text searching across multiple objects. To build a true search engine widget, you must wire a SOSL query in Apex to your LWC or Aura frontend.
📌 Key Points
- Multi-Object Power: A single SOSL
FINDstatement can query text, email, and phone fields across multiple objects simultaneously. - Apex Return Structure: SOSL returns a "List of Lists" (
List<List<sObject>>). You must map this back to your frontend. - Sanitization is Mandatory: Always use
String.escapeSingleQuotes()to prevent injection attacks when accepting search terms from a UI. - LWC vs. Aura: Both frameworks use the exact same Apex backend logic, differing only in how they render the HTML and handle navigation.
🏗️ 1. The Apex Backend: SOSL & Wrapper Classes
Because SOSL returns a complex List<List<sObject>>, passing raw arrays back to your JavaScript front-end gets messy fast. The best architectural pattern is to create an Apex Wrapper Class that neatly categorizes the results into named lists before sending them to the UI.
GlobalSearchController.cls)This controller handles the SOSL query, enforces security, and works perfectly for both LWC and Aura components.
public with sharing class GlobalSearchController {
// The Wrapper Class neatly packages results for the JavaScript frontend
public class SearchResultWrapper {
@AuraEnabled public List<Account> accounts { get; set; }
@AuraEnabled public List<Contact> contacts { get; set; }
@AuraEnabled public List<Opportunity> opportunities { get; set; }
public SearchResultWrapper() {
this.accounts = new List<Account>();
this.contacts = new List<Contact>();
this.opportunities = new List<Opportunity>();
}
}
// @AuraEnabled(cacheable=true) is recommended for LWC wire adapters
@AuraEnabled(cacheable=true)
public static SearchResultWrapper searchEntities(String searchTerm) {
SearchResultWrapper wrapper = new SearchResultWrapper();
if (String.isBlank(searchTerm) || searchTerm.trim().length() < 2) {
return wrapper; // Return empty wrapper if search is too short
}
// Safely sanitize the input and append the wildcard (*)
String sanitizedTerm = String.escapeSingleQuotes(searchTerm.trim()) + '*';
// Execute the SOSL query. Note the WITH USER_MODE security enforcement.
List<List<SObject>> searchResults = [
FIND :sanitizedTerm
IN ALL FIELDS
RETURNING
Account(Id, Name, Industry WITH USER_MODE),
Contact(Id, Name, Email WITH USER_MODE),
Opportunity(Id, Name, StageName WITH USER_MODE)
LIMIT 50
];
// Map the List of Lists back to the Wrapper properties
if (!searchResults.isEmpty()) {
wrapper.accounts = (List<Account>) searchResults[0];
wrapper.contacts = (List<Contact>) searchResults[1];
wrapper.opportunities = (List<Opportunity>) searchResults[2];
}
return wrapper;
}
}
⚡ 2. The Frontend: Lightning Web Components (LWC)
LWC is the modern standard for Salesforce UI. The component below takes the user's input, calls the Apex controller imperatively, and renders the categorized results.
soslSearch.js)
import { LightningElement, track } from 'lwc';
import { NavigationMixin } from 'lightning/navigation';
import searchEntities from '@salesforce/apex/GlobalSearchController.searchEntities';
export default class SoslSearch extends NavigationMixin(LightningElement) {
searchTerm = '';
@track results = { accounts: [], contacts: [], opportunities: [] };
// Dynamically check if we have any results to show
get hasResults() {
return (this.results.accounts.length > 0) ||
(this.results.contacts.length > 0) ||
(this.results.opportunities.length > 0);
}
handleInputChange(event) {
this.searchTerm = event.target.value;
}
async handleSearch() {
if (!this.searchTerm || this.searchTerm.length < 2) return;
try {
// Call Apex imperatively
this.results = await searchEntities({ searchTerm: this.searchTerm });
} catch (error) {
console.error('Search failed:', error);
}
}
// Safely route the user to the actual record page using standard navigation
handleNavigate(event) {
event.preventDefault();
const recordId = event.currentTarget.dataset.id;
this[NavigationMixin.Navigate]({
type: 'standard__recordPage',
attributes: {
recordId: recordId,
actionName: 'view'
}
});
}
}
⚙️ 3. The Frontend: Legacy Aura Components
If you are maintaining an older org or building a component for a context where LWC isn't fully supported yet, you can use the exact same Apex controller with an Aura frontend.
SearchComponentController.js)
({
searchRecords: function(component, event, helper) {
var searchTerm = component.get("v.searchTerm");
if (!searchTerm || searchTerm.length < 2) return;
component.set("v.isLoading", true);
// Setup the Apex action
var action = component.get("c.searchEntities");
action.setParams({ searchTerm: searchTerm });
action.setCallback(this, function(response) {
component.set("v.isLoading", false);
var state = response.getState();
if (state === "SUCCESS") {
// Bind the wrapper directly to an Aura attribute
component.set("v.searchData", response.getReturnValue());
} else {
console.error("Search failed.");
}
});
$A.enqueueAction(action);
}
})
🚨 4. Common Traps & SOSL Best Practices
Never build dynamic SOSL queries using raw string concatenation like
'FIND \'' + searchTerm + '\''. If a user types a quote or a dash, your code will crash, or worse, open you up to SOSL injection attacks. Always use static SOSL binds (FIND :sanitizedTerm) and manually clean the input using String.escapeSingleQuotes().
Never write raw anchor links like
<a href={'/' + result.Id}> in your UI. Hardcoded URLs break deep linking in Experience Cloud communities, Salesforce Mobile, and Lightning Console tabs. Always import NavigationMixin in LWC or use force:navigateToSObject in Aura.
WITH USER_MODE inside each object's RETURNING clause. This ensures your custom search bar respects the active user's Field-Level Security (FLS) and sharing rules automatically.
🎤 5. Core Interview Q&A
Q: What is the maximum number of records a SOSL query can return?
LIMIT clauses on each individual object inside the RETURNING statement (e.g., Account(Id LIMIT 50)).
Q: Why did you add an asterisk (*) to the search term in Apex?
Answer: The asterisk acts as a wildcard. If a user searches for "Uni", adding the asterisk turns it into "Uni*", which ensures the SOSL engine returns partial matches like "United Oil" or "Universal Containers".
🔗 6. Follow-Up Question
Q: How do you handle search performance if the user types too quickly?
Answer: In a real-world LWC, you should implement "Debouncing" in your JavaScript controller. Instead of firing an Apex callout on every single keystroke, you set a brief timer (e.g., 300ms). The timer resets on every keystroke, ensuring the Apex callout only fires after the user pauses typing. This saves massive amounts of server resources and prevents UI stuttering.
Comments
Questions, corrections and your own experience are all welcome.
Post a Comment