Standard SOQL queries are designed to fetch specific records from a single object or related parent-child hierarchies. When building global search bars or multi-entity lookup utilities, executing multiple separate SOQL queries exhausts transaction limits and creates performance lag. Combining SOSL with Lightning Web Components delivers an optimized search engine that queries multiple objects simultaneously in a single roundtrip.
1. SOSL vs. SOQL Architecture in Salesforce
Understanding when to choose SOSL over SOQL ensures optimal resource utilization:
- Multi-Object Capability: A single SOSL
FINDstatement can query up to 2,000 total records across multiple sObjects simultaneously using theRETURNINGclause. - Full-Text Indexing: SOSL evaluates text, phone, email, and picklist fields using Salesforce's automated search indexes, making it significantly faster for free-text search than SOQL
LIKE '%term%'operations. - Apex Search Return Types: In Apex, SOSL queries executed via
Search.query()return a list of lists of sObjects (List<List<SObject>>), where each inner list matches the corresponding object specified in theRETURNINGclause.
- Query Syntax:
FIND 'searchTerm*' IN ALL FIELDS RETURNING Account(Id, Name), Contact(Id, Name, Email) - Apex Data Type:
List<List<SObject>> - Sanitization Method: Always sanitize free text with
String.escapeSingleQuotes(). - LWC Navigation: Standard
NavigationMixin.Navigatefor lightning record page routing.
2. Implementing the Apex Search Controller
The Apex controller below executes a parameterized SOSL search across Accounts and Contacts, returning a structured wrapper map that is easily consumed by Lightning Web Components.
GlobalSearchController.cls)
public with sharing class GlobalSearchController {
public class SearchResultWrapper {
@AuraEnabled public List<Account> accounts { get; set; }
@AuraEnabled public List<Contact> contacts { get; set; }
public SearchResultWrapper() {
this.accounts = new List<Account>();
this.contacts = new List<Contact>();
}
}
@AuraEnabled(cacheable=true)
public static SearchResultWrapper searchEntities(String searchTerm) {
SearchResultWrapper wrapper = new SearchResultWrapper();
if (String.isBlank(searchTerm) || searchTerm.trim().length() < 2) {
return wrapper;
}
// Sanitize search phrase and append wildcard
String sanitizedTerm = String.escapeSingleQuotes(searchTerm.trim()) + '*';
// Execute multi-object SOSL search with user security
List<List<SObject>> searchResults = [
FIND :sanitizedTerm
IN ALL FIELDS
RETURNING
Account(Id, Name, Industry, Phone WITH USER_MODE),
Contact(Id, Name, Email, Title WITH USER_MODE)
LIMIT 50
];
if (!searchResults.isEmpty()) {
wrapper.accounts = (List<Account>) searchResults[0];
wrapper.contacts = (List<Contact>) searchResults[1];
}
return wrapper;
}
}
3. Building the LWC Search Interface
The front-end component handles search input, invokes the Apex controller imperatively, and routes users to clicked records using NavigationMixin.
soslSearch.html)
<template>
<lightning-card title="Enterprise SOSL Search" icon-name="standard:search">
<div class="slds-p-around_medium">
<div class="slds-grid slds-gutters slds-grid_vertical-align-end slds-m-bottom_medium">
<div class="slds-col slds-size_8-of-12">
<lightning-input
type="search"
label="Search Across Objects"
placeholder="Type at least 2 characters..."
value={searchTerm}
onchange={handleInputChange}>
</lightning-input>
</div>
<div class="slds-col slds-size_4-of-12">
<lightning-button
label="Search"
variant="brand"
icon-name="utility:search"
onclick={handleSearch}>
</lightning-button>
</div>
</div>
<!-- Search Results Display -->
<template lwc:if={hasResults}>
<div class="slds-grid slds-gutters slds-wrap">
<!-- Accounts Column -->
<div class="slds-col slds-size_1-of-1 slds-medium-size_1-of-2">
<h3 class="slds-text-title_bold slds-m-bottom_small">
Accounts ({results.accounts.length})
</h3>
<ul class="slds-has-dividers_bottom-space">
<template for:each={results.accounts} for:item="acc">
<li key={acc.Id} class="slds-item">
<a href="javascript:void(0);" data-id={acc.Id} onclick={handleNavigateToRecord}>
<strong>{acc.Name}</strong>
</a>
<p class="slds-text-body_small slds-text-color_weak">Industry: {acc.Industry}</p>
</li>
</template>
</ul>
</div>
<!-- Contacts Column -->
<div class="slds-col slds-size_1-of-1 slds-medium-size_1-of-2">
<h3 class="slds-text-title_bold slds-m-bottom_small">
Contacts ({results.contacts.length})
</h3>
<ul class="slds-has-dividers_bottom-space">
<template for:each={results.contacts} for:item="con">
<li key={con.Id} class="slds-item">
<a href="javascript:void(0);" data-id={con.Id} onclick={handleNavigateToRecord}>
<strong>{con.Name}</strong>
</a>
<p class="slds-text-body_small slds-text-color_weak">Email: {con.Email}</p>
</li>
</template>
</ul>
</div>
</div>
</template>
</div>
</lightning-card>
</template>
soslSearch.js)
import { LightningElement, track } from 'lwc';
import { NavigationMixin } from 'lightning/navigation';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import searchEntities from '@salesforce/apex/GlobalSearchController.searchEntities';
export default class SoslSearch extends NavigationMixin(LightningElement) {
searchTerm = '';
@track results = { accounts: [], contacts: [] };
get hasResults() {
return (this.results.accounts && this.results.accounts.length > 0) ||
(this.results.contacts && this.results.contacts.length > 0);
}
handleInputChange(event) {
this.searchTerm = event.target.value;
}
async handleSearch() {
if (!this.searchTerm || this.searchTerm.trim().length < 2) {
this.showToast('Search Error', 'Please enter at least 2 characters to search.', 'warning');
return;
}
try {
const data = await searchEntities({ searchTerm: this.searchTerm });
this.results = data;
if (!this.hasResults) {
this.showToast('No Records Found', 'No matches found for your search criteria.', 'info');
}
} catch (error) {
this.showToast('Query Error', error.body ? error.body.message : error.message, 'error');
}
}
handleNavigateToRecord(event) {
event.preventDefault();
const recordId = event.currentTarget.dataset.id;
this[NavigationMixin.Navigate]({
type: 'standard__recordPage',
attributes: {
recordId: recordId,
actionName: 'view'
}
});
}
showToast(title, message, variant) {
this.dispatchEvent(new ShowToastEvent({ title, message, variant }));
}
}
4. Common Traps & Platform Limitations
Writing raw anchor links like
<a href={'/' + result.Id}> breaks deep linking in Experience Cloud communities, Salesforce Mobile, and Lightning Console tabs. Always import NavigationMixin from lightning/navigation and trigger standard standard__recordPage navigation events to preserve platform routing.
- Minimum Search Length: Salesforce search indexes typically require at least 2 characters to execute wildcard queries effectively.
- Wildcard Handling: Append the asterisk wildcard (
*) to search terms so partial matches (e.g., "Uni*" matching "United Oil") return reliably. - Enforce User Mode Security: Add
WITH USER_MODEinside each object'sRETURNINGclause to respect Field-Level Security and object sharing rules automatically.
Summary
Combining SOSL with Lightning Web Components creates a fast, multi-object search experience for Salesforce users. By structuring clean Apex wrapper controllers, sanitizing input parameters, and using standard NavigationMixin events, developers can build scalable search interfaces across desktop and mobile Lightning environments.