@salesforce/ui-bundle-template-feature-react-search
v12.4.5
Published
[Beta] Configuration-driven multi-source search for UI Bundles
Maintainers
Keywords
Readme
@salesforce/ui-bundle-template-feature-react-search
Configuration-driven, multi-source search for React applications on the Salesforce platform.
⚛️ React only. This package ships React components and hooks built on
react19 andreact-router7. Use it in a React application; it is not compatible with LWC or other non-React UIs — see Requirements.
You describe what is searchable in a single config object; the package owns how it's searched — GraphQL query construction, filter/sort/pagination state, URL sync, default rendering, and the orchestration UI. Most apps drop in one component and get a complete search page with zero custom code:
import { Search, config } from "@salesforce/ui-bundle-template-feature-react-search";
export default function SearchPage() {
return <Search config={config} />;
}Adding a new searchable sObject or CMS content channel is a single entry in your config — no code changes.
Table of contents
- Requirements
- Installation
- Quick start
- The
<Search>component - Configuration
- Use cases
- 1. Unified search across many objects
- 2. Single-object search (
restrictTo) - 3. Hand off a term from another page
- 4. Custom row layout for one source
- 5. Hide a source from the results
- 6. Replace a source's filter sidebar
- 7. Headless — drive your own UI with
useSearch - 8. Mixed card layouts per source
- 9. Global pagination across all sources
- 10. Build a custom unified-results UI (grid + pager + filters + count)
- 11. Choosing a merge order (sequential vs proportional)
- URL and state conventions
- Advanced: CMS search
- Internal structure
- Public API
- Troubleshooting & common mistakes
Requirements
This is a React-only package. Your application must provide:
- React 19 and react-router 7 — the components render React elements and use react-router for navigation, URL sync, and result links.
@salesforce/platform-sdk— sObject sources are fetched viacreateDataSDK().graphql.query()against the Salesforce uiapi GraphQL bridge; CMS sources are fetched through the Managed Content search API (see Advanced: CMS search).- lucide-react — used for the control icons.
Your app must be wrapped in a react-router router (e.g. a <BrowserRouter> or a
data router), since the search components read and write the URL.
Installation
npm install @salesforce/ui-bundle-template-feature-react-searchThen import what you need from the package entry point:
import { Search, config } from "@salesforce/ui-bundle-template-feature-react-search";Quick start
The package ships an example config you can import to try things out
immediately. Render the drop-in <Search> against it:
import { Search, config } from "@salesforce/ui-bundle-template-feature-react-search";
export default function SearchPage() {
return <Search config={config} title="Search" searchPlaceholder="Search across Salesforce…" />;
}This renders: a search bar, a live total-result count, a scope dropdown (when
the config has 2+ sources), and one results section per source — each with
auto-rendered rows, a filter sidebar, a sort dropdown, per-source pagination,
and a per-source Reset button inside its filter panel. (Reset is per-source;
there is no global reset button.) Set pagination.mode: "merged" and the same
component instead renders one combined grid with a single shared pager
(§9). Everything is driven by the
config you pass in.
For a real application you'll typically pass your own config.
The <Search> component
<Search> is the top of a layered API. Use it as-is, override individual
slots, or drop down to the useSearch hook for full control:
<Search config /> ← drop-in (most apps)
│
├── <SearchBar /> + <SearchResults /> ← compose primitives + own header
│ └── <SourceSection /> per source
│ ├── <DefaultResultRow /> ← override per source
│ └── <DefaultFilterPanel /> ← override per source
│
└── useSearch(config) ← headless: build your own UIProps
| Prop | Type | Default | Purpose |
| ------------------- | ------------------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------- |
| config | SearchConfig | — (required) | The source list. Pass your own, or the example config export. |
| title | string | "Search" | Heading shown by the default header. |
| subtitle | string | — | Sub-heading shown by the default header. |
| searchPlaceholder | string | "Search…" | Placeholder for the search input. |
| restrictTo | { kind: "sobject"; key: string } | — | Lock the page to one source. Hides the scope dropdown and only fetches/renders that source. |
| showScopeSelector | boolean | true when 2+ sources | Force-show or hide the scope dropdown. Always hidden when restrictTo is set. |
| allScopeLabel | string | "All" | Label for the "search everything" entry in the scope dropdown. |
| renderResult | Record<string, ((node) => ReactNode) \| false> | — | Per source key: custom row renderer, or false to hide that source. |
| renderFilters | Record<string, (() => ReactNode) \| false> | — | Per source key: custom filter sidebar, or false to suppress filters for that source. |
| emptyMessages | Record<string, string> | — | Per source key: message shown when that source has no results. |
| renderHeader | (handle: SearchHandle) => ReactNode | built-in title/subtitle header | Replace the entire header region. |
| className | string | — | Extra classes on the outer container. |
renderResult / renderFilters / emptyMessages are keyed by the source
key from your config (e.g. accounts, contacts, content).
Mounting
<Search>behind a launcher input (e.g. a home-page search box) has one routing caveat of its own — see MountingGlobalSearchBox+<Search>at/search.
Configuration
A SearchConfig is { sources: SourceConfig[] } plus an optional global
pagination block, where each source is either an SObjectSourceConfig
(sObject source config) or a CmsSourceConfig
(CMS source config). Define it in your own code and pass
it to <Search config={...} />:
import { Search } from "@salesforce/ui-bundle-template-feature-react-search";
import type { SearchConfig } from "@salesforce/ui-bundle-template-feature-react-search";
const config: SearchConfig = {
// One global pagination block — the single source of truth for the whole
// search experience. `mode` / `mergeOrder` / `pageSize` / `pageSizeOptions`
// apply to every object type and in every scope, including when the dropdown
// narrows to one source and on single-object pages (restrictTo / lockedScope).
// Omit to default to per-source mode, page size 10, options [10, 25, 50].
pagination: {
mode: "merged", // "per-source" (default) | "merged"
mergeOrder: "proportional", // "sequential" (default) | "interleaved" | "proportional"
pageSize: 12,
pageSizeOptions: [12, 24, 48],
},
sources: [
{
kind: "sobject",
key: "accounts",
objectName: "Account",
label: "Accounts",
routePattern: "/accounts/:id",
searchableFields: ["Name", "Phone", "Industry"],
displayFields: ["Name", "Industry", "Phone"],
},
],
};
export default function SearchPage() {
return <Search config={config} />;
}One pagination config for everything. There is no per-source pagination
config — config.pagination is the single source of truth. The same
pageSize / pageSizeOptions apply across every object type and in every
scope — the unified /search page, when the scope dropdown narrows to one
source, and single-object pages locked with restrictTo / lockedScope.
How that config is displayed depends on mode: the default "per-source"
paginates each object's section on its own cursor, while "merged" collapses
in-scope sources into one combined grid whose page order is set by mergeOrder
(see §9).
You can also keep the config as a JSON file and import it — cast it to
SearchConfig since the structure is not validated at runtime (see the note at
the end of A complete annotated sObject source).
Sources come in two kinds: "sobject" (queried through the platform-sdk
uiapi GraphQL bridge) and "cms" (queried through the Managed Content
search API). See sObject source config and
CMS source config below, and
Advanced: CMS search for how CMS sources are wired
end to end.
Global pagination (SearchConfig.pagination)
Optional. One block governs pagination for the whole search experience —
sources carry no pagination config of their own. Omit it to default to
per-source mode, page size 10, options [10, 25, 50]. (Note: mode
"per-source" still paginates each object's section independently — see the
mode row below and §9.)
| Field | Required | Type | Description |
| ----------------- | -------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| pageSize | ✅ | number | Items per page (across all sources in "merged" mode; per source otherwise). |
| pageSizeOptions | ✅ | number[] | Page sizes offered in the "results per page" control. |
| mode | | "per-source" | "merged" (default per-source) | "merged" windows the cumulative result set into fixed pages of pageSize; "per-source" advances each source's own cursor. See §9. |
| mergeOrder | | "sequential" | "interleaved" | "proportional" (default sequential) | In "merged" mode, how nodes from different sources are ordered. Ignored in "per-source" mode. See §9. |
sObject source config
sObject sources work out of the box — no runtime id resolution and no gating.
They query the org's uiapi GraphQL bridge through the sobject adapter
(adapters/sobject).
| Field | Required | Type | Description |
| ------------------ | -------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| kind | ✅ | "sobject" | Discriminator selecting the runtime adapter. |
| key | ✅ | string | Stable id — used as the GraphQL alias, URL namespace, and result-map key. Must match /^[A-Za-z_][A-Za-z0-9_]*$/. |
| objectName | ✅ | string | GraphQL object name ("Account", "Contact", …). |
| label | ✅ | string | Plural display label — section header, scope dropdown, empty-state messages ("Accounts"). |
| labelSingular | | string | Singular label for one record ("Account") — used for the merged-grid source badge. Falls back to label. |
| searchableFields | ✅ | string[] | Fields the global q term matches. OR-ed as like %q%. Supports dot-paths ("Owner.Name"). |
| displayFields | ✅ | DisplayField[] | Fields selected in the GraphQL query and used by the default row layout. (See below.) |
| routePattern | | string | Makes the default row a <Link>. Tokens like :id / :Name are substituted per record. Your app must register a matching route to render the target (details). Omit for non-clickable rows. |
| idField | | string (default "Id") | Unique-id field. Drives the :id route token. Always selected automatically — do not list it in displayFields. |
| filterBy | | FilterFieldConfig[] | Structured filters for the source. Today each renders an input in the per-source sidebar. |
| sortBy | | SortFieldConfig[] | Options in the per-source sort dropdown. |
| defaultSort | | { field, direction } | Initial sort applied when the URL has none. direction is "ASC" or "DESC". |
| whereTypeName | | string | GraphQL where variable type. Defaults to ${objectName}_Filter. Override only for non-conventional schemas. |
| orderByTypeName | | string | GraphQL orderBy variable type. Defaults to ${objectName}_OrderBy. |
displayFields shapes
Each entry controls both the GraphQL selection set and the default row
layout (first entry → row title; the rest → subtitle, joined with ·):
| Form | GraphQL emitted | Use for |
| ---------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------- |
| "Name" (string) | Name @optional { value displayValue } | Ordinary scalar fields. |
| { name: "Owner", subfields: ["Name"] } | Owner @optional { Name @optional { … } } | Relationship traversal (parent/lookup). |
| { name: "SomeId", raw: true } | SomeId | Fields not wrapped in { value displayValue } (Id-like scalars). |
The
idField(default"Id") is always emitted — never list it explicitly.
filterBy types
Each FilterFieldConfig is { field, label, type, options?, placeholder?, dateMode?, helpText?, min?, max? }.
The type drives which input renders and how the where clause is built. min/max (numeric-only) declare an inclusive bound, enforced in both the input widget and the where clause.
| type | UI | where clause |
| --------------- | ---------------------------------- | -------------------------------------------- |
| text | Text input | { field: { like: "%value%" } } |
| picklist | Single-select dropdown | { field: { eq: value } } |
| multipicklist | Multi-select dropdown (checkboxes) | { field: { in: [...] } } (or eq for one) |
| numeric | min / max number inputs | { field: { gte, lte } } |
| boolean | Tri-state (any/true/false) | { field: { eq: true \| false } } |
| date | See dateMode | { field: { gte \| lte: { value } } } |
| daterange | See dateMode | and of gte / lte |
| datetime | See dateMode | start/end-of-day ISO bounds |
| datetimerange | See dateMode | and of start/end-of-day ISO bounds |
For picklist / multipicklist, options are auto-fetched from the GraphQL
aggregate API (groupBy) on first render. Skip the fetch by supplying inline
options: [{ value, label }] — required for fields that aren't group-by-able
(formulas, long text, etc.).
For the date-family types, dateMode selects the comparison chrome (defaults to
"comparison"):
| dateMode | UI |
| -------------- | ------------------------------------------------------------------- |
| "comparison" | An After / Before selector with one date input (default). |
| "range" | A single Between control — two date inputs (lower–upper bound). |
| "both" | A Between / After / Before selector spanning the two. |
sortBy and defaultSort
sortBy is a list of { field, label }. Each becomes an option in the
per-source sort dropdown (with an ASC/DESC toggle). defaultSort sets the
initial order when no sort is present in the URL:
"sortBy": [
{ "field": "CloseDate", "label": "Close Date" },
{ "field": "Amount", "label": "Amount" }
],
"defaultSort": { "field": "CloseDate", "direction": "DESC" }Omit sortBy entirely to hide the sort dropdown for that source.
Query construction. buildSearchQuery (api/buildSearchQuery.ts)
assembles the GraphQL from the source config, buildOrderBy builds the sort
clause, and picklist filter values come from useDistinctValues /
fetchDistinctValues (see Public API). The whole path runs
through the platform data SDK — no enablement step beyond declaring the
source.
FLS caveat. A field the running user can't read is silently dropped by the platform, so an sObject source that "returns nothing" usually means the app's permission set is missing field access, not a config error.
CMS source config
A CMS source (kind: "cms") is deliberately minimal — there's no channelId,
no content types, and no displayFields in config; all of that is resolved at
runtime (see Advanced: CMS search). It's routed via
adapters/registry.ts to cmsAdapter (adapters/cms/index.ts).
| Field | Required | Type | Description |
| --------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| kind | ✅ | "cms" | Discriminator selecting the CMS adapter. |
| key | ✅ | string | Stable id — GraphQL alias, URL namespace, result-map key. Must match /^[A-Za-z_][A-Za-z0-9_]*$/, same as sObject sources. |
| label | ✅ | string | Plural display label — section header, scope dropdown, empty-state messages. |
| labelSingular | | string | Singular label for one item — used for the merged-grid source badge. Falls back to label. |
| routePattern | | string | Makes the default row a <Link>. Tokens are :id (the managedContentId), :key, and :contentType. Falls back to /content/:contentType/:id when omitted. See routePattern & detail-page linking. |
CMS & misc knobs
| Knob | Values / notes |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Per-cms-source | key, label, labelSingular?, routePattern? — nothing else, by design. |
| MIN_QUERY_LENGTH | constants.ts, currently 3. Both backends reject shorter terms server-side; gated client-side in useSearch.ts and GlobalSearchBox.tsx so the UI never shows a spurious failure for a too-short query. |
routePattern & detail-page linking
By default, result cards are NOT clickable — the feature renders each row as
plain (non-linked) text (see the final return <div>… branch in
DefaultResultRow.tsx / CmsResultRow.tsx). Making a card clickable is a
two-part change, and both parts are required — one without the other either
does nothing or 404s:
- In this feature's config: give the source a
routePattern. This is the only thing that turns the row into a<Link>. Without it the row stays non-clickable no matter what routes the app defines. - In the app (e.g.
src/routes.tsx): the detail page must actually exist and be registered at a matching dynamic route (e.g.<Route path="/accounts/:id" element={<AccountDetail />} />, implementing anAccountDetailpage/component that reads the:idparam and renders that object's details). The feature only builds the href (e.g./accounts/001…); it does not own any route. If that route isn't registered, the (now-clickable) card navigates to a dead URL and 404s.
So: routePattern present and a matching detail route/page in the app →
clickable card that opens a detail page. routePattern absent → non-clickable
card (the default). routePattern present but no app route → clickable card
that 404s. Point routePattern at routes you already have (or add them
alongside), and give every searchable source its own detail route. If you don't
want clickable rows — for example when details open in a modal instead of on a
route — omit routePattern entirely (this is what the propertymanagementapp
template does: it has no routePattern and opens a detail modal from a custom
renderResult instead).
- How the link is built:
routePatternis a path template with:tokenplaceholders resolved per result.- sObject rows (
components/results/DefaultResultRow.tsx,resolveRoute(routePattern, node, idField))::fieldNametokens are substituted from the record's field values, and a bare:idmaps to the source'sidField(defaultId). Each token is a single field name — unlikesearchableFields,routePatterntokens do not support dot-paths (:Owner.Nameresolves only:Ownerand leaves the row non-clickable via the runtime-null fallback below). For a nested value, expose it as a flat field or userenderResult. The shippedaccountssource uses"/accounts/:id". - CMS rows (
components/results/CmsResultRow.tsx): tokens are:id(themanagedContentId),:key, and:contentType; when noroutePatternis set it falls back to/content/:contentType/:id. - Runtime-null fallback: if any token resolves to
nullfor a record, that row falls back to a plain, non-clickable layout rather than emitting a broken link. Make sure tokenized fields are present indisplayFields.
- sObject rows (
- Authoring the app-side route + page (part 2 above): register the matching
dynamic route in the app's
routes.tsx(e.g.path: "accounts/:id"/path: "content/:contentType/:id"). That route's component reads the route param (useParams) and fetches the single record/content item — sObject detail: fetch the one record by id via the platform data SDK (see theexperience-ui-bundle-salesforce-data-accessskill — do not hand-rollfetch); CMS detail: fetch the content item bymanagedContentIdthrough the CMS delivery API. - Generating the page: whichever kind of detail page you need, nothing inside
this search feature changes beyond setting
routePattern— the page and route live in the app, not in this directory. Pick the skill by source type:- sObject object-detail page — a normal routed page; generate it with the
experience-ui-bundle-frontend-generateskill (its page types include "detail view"; record data is fetched via the platform Data SDK per theexperience-ui-bundle-salesforce-data-accessskill). - CMS content-detail page — generate it with the
experience-cms-content-renderskill. That skill owns CMS content fetch + render (the delivery API,contentKey/contentType, RichText, and image resolution) and takes precedence overexperience-ui-bundle-frontend-generatefor the rendering portion — the UI skill owns only the page/layout shell. Its Detail Page branch writessrc/pages/<type>/<PageName>.tsxand inserts the route, so the app gets both the page and the matching route wired for you. (It is scoped to rendering existing content — use this search feature, not that skill, for the search itself.)
- sObject object-detail page — a normal routed page; generate it with the
A complete annotated sObject source
{
"kind": "sobject", // discriminator
"key": "leads", // GraphQL alias + URL namespace + result key
"objectName": "Lead", // GraphQL type
"label": "Leads", // plural — section header + scope option
"labelSingular": "Lead", // singular — merged-grid source badge (optional; falls back to label)
"routePattern": "/leads/:id", // default row → <Link to="/leads/<id>">
"idField": "Id", // default; drives :id (don't add to displayFields)
"searchableFields": ["Name", "Company", "Email"], // global q is OR-ed across these
"displayFields": [
"Name", // row title
"Title", // ┐
"Company", // ├ subtitle (joined with " · ")
"Email", // ┘
{ "name": "Owner", "subfields": ["Name"] }, // relationship traversal
],
"filterBy": [
{ "field": "Status", "label": "Status", "type": "picklist" },
{ "field": "Industry", "label": "Industry", "type": "picklist" },
],
"sortBy": [
{ "field": "Name", "label": "Name" },
{ "field": "CreatedDate", "label": "Created Date" },
],
"defaultSort": { "field": "CreatedDate", "direction": "DESC" },
}That single entry gives Leads a full search experience: a global-term match
across name/company/email, two auto-populated picklist filters, a sort
dropdown, pagination (governed by the top-level pagination block), and
clickable result rows linking to /leads/<id>.
No runtime schema validation. A config object is trusted as-is; a typo surfaces at GraphQL query time, not when the config loads. Layer your own validation (e.g. a zod schema) if you need stricter guarantees.
A complete annotated CMS source
{
"kind": "cms", // discriminator selecting the CMS adapter
"key": "content", // GraphQL alias + URL namespace + result key
"label": "Content", // plural — section header + scope option
"labelSingular": "Content Item", // singular — merged-grid source badge (optional; falls back to label)
"routePattern": "/content/:contentType/:id", // optional; this is also the built-in fallback when omitted
}That's the entire config surface for a CMS source — channel resolution,
content-type discovery, and query construction all happen at runtime (see
Advanced: CMS search). There is no displayFields,
filterBy, or sortBy for CMS sources today; rows render through
CmsResultRow and scope/content-type entries in the dropdown come from the
content types discovered at runtime, not from config.
Use cases
1. Unified search across many objects
The default. Every source in the config is searched from one box; a scope dropdown lets the user narrow to a single object. Best for a global "search everything" page.
<Search config={config} title="Search" />2. Single-object search (restrictTo)
Reuse the same config but lock the page to one source. The scope dropdown is hidden and only the matching source is fetched and rendered — ideal for a dedicated "Account search" or "Browse Contacts" page where the object is implied by the route.
<Search
config={config}
title="Search Accounts"
searchPlaceholder="Search by name, phone, or industry…"
restrictTo={{ kind: "sobject", key: "accounts" }}
/>3. Hand off a term from another page
The global term lives in the URL under ?q= (the exported GLOBAL_QUERY_KEY).
Navigate to the search page with ?q= pre-filled and the input + results
populate on arrival — handy for a simple search box on a Home page:
import { useNavigate } from "react-router";
function HomeSearchBox() {
const navigate = useNavigate();
const onSubmit = (term: string) => {
// `q` matches GLOBAL_QUERY_KEY, so the search page pre-fills on arrival.
navigate(`/accounts${term ? `?q=${encodeURIComponent(term)}` : ""}`);
};
// …render an input that calls onSubmit…
}See also Advanced: CMS search → Mounting
GlobalSearchBox+<Search>at/search— the package ships a ready-madeGlobalSearchBoxlauncher that does exactly this navigation for you, as long as you mount<Search>(or your own results UI) at the route it navigates to.
4. Custom row layout for one source
Override the default renderer for specific sources by key; others keep the
default. Return ReactNode from (node) => … — narrow node to your own
type. Use the exported fieldValue() to read { value, displayValue } fields:
import { Search, fieldValue } from "@salesforce/ui-bundle-template-feature-react-search";
<Search
config={config}
renderResult={{
opportunities: (node) => {
const o = node as OpportunityNode;
return (
<div className="flex justify-between">
<span>{fieldValue(o.Name)}</span>
<span className="text-muted-foreground">{fieldValue(o.StageName)}</span>
</div>
);
},
}}
/>;5. Hide a source from the results
Pass false for that source's renderResult. The source skips both the
network request and its UI section:
<Search config={config} renderResult={{ accounts: false }} />6. Replace a source's filter sidebar
Supply your own filter UI built from the exported filter inputs (they wire into
the search state via FilterContext). Pass false to suppress filters
entirely for a source.
import {
Search,
TextFilter,
SelectFilter,
} from "@salesforce/ui-bundle-template-feature-react-search";
<Search
config={config}
renderFilters={{
accounts: () => (
<>
<TextFilter field="Name" label="Account Name" />
{/* SelectFilter / MultiSelectFilter take an explicit `options` list. */}
<SelectFilter
field="Industry"
label="Industry"
options={[
{ value: "Technology", label: "Technology" },
{ value: "Finance", label: "Finance" },
]}
/>
</>
),
}}
/>;7. Headless — drive your own UI with useSearch
Skip the orchestration components entirely and build a bespoke layout. The hook
owns state, URL sync, and fetching; you render whatever you like from the
returned SearchHandle.
import { useSearch } from "@salesforce/ui-bundle-template-feature-react-search";
function MySearch() {
const { q, setQ, sources, loading, error, resetAll } = useSearch(config);
// sources[key].result.nodes, .filters, .sort, .pagination …
// build your own UI from these.
}Lock the headless hook to a single source with
useSearch(config, { lockedScope: "accounts" }).
8. Mixed card layouts per source
renderResult is keyed by source, so each object type can render a completely
different card while the search bar, scope, and pagination stay shared. Sources
without an entry fall back to the default row. Here Accounts render as a stat
card and Contacts as an avatar row:
import { Search, fieldValue } from "@salesforce/ui-bundle-template-feature-react-search";
<Search
config={config}
renderResult={{
// Accounts → a bordered "stat" card.
accounts: (node) => {
const a = node as AccountNode;
return (
<div className="rounded-lg border p-4 flex items-center justify-between">
<div>
<p className="font-semibold">{fieldValue(a.Name)}</p>
<p className="text-sm text-muted-foreground">{fieldValue(a.Industry)}</p>
</div>
<span className="text-sm tabular-nums">{fieldValue(a.AnnualRevenue) ?? "—"}</span>
</div>
);
},
// Contacts → an avatar + two-line card.
contacts: (node) => {
const c = node as ContactNode;
const name = fieldValue(c.Name) ?? "—";
return (
<div className="flex items-center gap-3 py-1">
<span className="flex size-9 items-center justify-center rounded-full bg-muted text-sm font-medium">
{name.slice(0, 1)}
</span>
<div>
<p className="font-medium">{name}</p>
<p className="text-sm text-muted-foreground">{fieldValue(c.Email)}</p>
</div>
</div>
);
},
// `opportunities` omitted → keeps the built-in default row.
}}
/>;To render every source's results as cards in a responsive grid instead of the
default stacked list, drop down to the headless hook (next example shows the
same pattern) and wrap each source's result.nodes in your own grid container.
9. Global pagination across all sources
By default the drop-in <Search> paginates each source independently. Set
config.pagination.mode: "merged" and <Search> switches to a single combined
grid with one shared pager over the cumulative result set automatically — no
prop needed (that's exactly what §10
assembles by hand for a fully custom UI). Either way, the merged behaviour is
built from three aggregate accessors useSearch derives from the in-scope
sources:
| Accessor | Use |
| ------------------------- | ----------------------------------------------------------------------------- |
| handle.inScopeSources | The SourceController[] participating in the current scope (config order). |
| handle.mergedResults | In-scope nodes flattened into one { sourceKey, node }[] for a single grid. |
| handle.globalPagination | Aggregate pageIndex / pageCount / pageSize / hasNext… + page actions. |
Pick a pagination mode via config.pagination.mode:
| Mode | mergedResults | Page size means… | goToPage |
| -------------- | ------------------------------------ | --------------------------------- | ---------------- |
| "merged" | exactly the current page's window | items per page (e.g. 6 total) | jump to any page |
| "per-source" | every in-scope source's current page | items per source (6 × N) | adjacent only |
Use "merged" when you want a true combined list — "6 per page" shows exactly 6
cards and pageCount = ceil(totalCount / pageSize). The default "per-source"
keeps each source on its own cursor (cheaper for deep lists, but a page can hold
up to sources × pageSize items).
Order sources with config.pagination.mergeOrder:
| Order | mergedResults layout |
| ------------------------ | ----------------------------------------------------------------------- |
| "sequential" (default) | all of source 1, then source 2, … — the first source leads. |
| "interleaved" | round-robin, one per source per round — every source ranks equally. |
| "proportional" | each source spread evenly by its result count — share matches size. |
Pass "interleaved" to give every object type equal footing regardless of how
many results it has; pass "proportional" to weight types by their result count
(a source with 100 hits appears ~10× as often as one with 10, so each page
mirrors the overall mix). Proportional reads each source's totalCount. Page
boundaries stay exact under all three orders.
import {
useSearch,
SearchBar,
DefaultResultRow,
PaginationControls,
} from "@salesforce/ui-bundle-template-feature-react-search";
function GloballyPaginatedSearch() {
// mode + mergeOrder live in config.pagination (e.g. { mode: "merged",
// mergeOrder: "interleaved", pageSize: 6, pageSizeOptions: [6, 12, 24] }).
const handle = useSearch(config);
const { mergedResults, globalPagination: page } = handle;
// One combined, cumulatively-paginated grid. Each item knows its source, so
// you can pick the right card/row per node.
const sourceByKey = Object.fromEntries(
handle.inScopeSources.map((c) => [c.config.key, c.config]),
);
return (
<div className="max-w-4xl mx-auto py-6 space-y-6">
<SearchBar value={handle.q} onChange={handle.setQ} placeholder="Search…" />
<p className="text-sm text-muted-foreground">
{page.totalCount} results · Page {page.pageIndex + 1} of {page.pageCount}
</p>
<ul className="divide-y">
{mergedResults.map(({ sourceKey, node }, i) => (
<li key={i} className="py-2">
<DefaultResultRow node={node} source={sourceByKey[sourceKey]} />
</li>
))}
</ul>
{/* Passing pageCount + onGoToPage renders numbered page buttons. */}
<PaginationControls
pageIndex={page.pageIndex}
pageCount={page.pageCount}
hasNextPage={page.hasNextPage}
hasPreviousPage={page.hasPreviousPage}
pageSize={page.pageSize}
pageSizeOptions={page.pageSizeOptions}
onNextPage={page.goToNextPage}
onPreviousPage={page.goToPreviousPage}
onGoToPage={page.goToPage}
onPageSizeChange={page.setPageSize}
disabled={handle.loading}
/>
</div>
);
}These accessors track scope automatically: in "all" scope they span every
source; under a single scope (or lockedScope) they collapse to just that one,
so the same layout serves both the unified page and per-object pages.
See §10 for a complete custom UI — merged grid, pagination widget, per-source filter panels, and a result count — assembled from these accessors and the exported primitives.
How
"merged"is exact. For global pageP, every in-scope source fetches(P + 1) × pageSizerows from its front; concatenating them in config order and slicing[P × pageSize, (P + 1) × pageSize)yields the precise page, because the first(P + 1) × pageSizeitems of the concatenation are always the true cumulative prefix. Cost grows with page depth (each Next refetches a larger window), so it suits a combined grid, not an infinite feed.
"per-source"model. Sources paginate with independent cursors — no global offset — so the "global page" is the furthest-advanced in-scope source's page.goToNextPageadvances only sources that still have data; a source that runs out is left behind and drops out ofmergedResultson later pages (its final page was already shown).pageCountis the max ofceil(totalCount / pageSize)across in-scope sources, andgoToPagehonours only adjacent pages. For fully independent pagers, render one<SourceSection>(orPaginationControls) perhandle.sources[key]and skip the aggregate API.
10. Build a custom unified-results UI (grid + pager + filters + count)
For a combined card grid with one shared pager, a total-results
count, and per-source filter panels, the fastest path is
config.pagination.mode: "merged" on the drop-in <Search> — it renders all of
this (§9). Drop to the headless
recipe below only when you need control the drop-in doesn't expose (a bespoke
grid container, custom chrome placement, extra surrounding UI): assemble it
yourself from useSearch plus the exported primitives. This is what the
propertymanagementapp template's UnifiedSearch component used to hand-roll
before adopting merged mode; the skeleton below is the same recipe, trimmed to
the essentials.
The pieces you compose:
| Piece | What renders it | Source of truth |
| --------------------- | --------------------------------------------------------------- | ------------------------------------- |
| Search box | <SearchBar value={handle.q} onChange={handle.setQ} /> | handle.q / handle.setQ |
| Scope selector | <ScopeSelector config scope onScopeChange /> | handle.scope / handle.setScope |
| Result count | your own <p> reading page.totalCount | handle.globalPagination.totalCount |
| Merged grid | map over handle.mergedResults → your card per sourceKey | handle.mergedResults |
| Pagination widget | <PaginationControls …{page} /> | handle.globalPagination |
| Filter panel | <FilterProvider> + <DefaultFilterPanel source> (per source) | handle.inScopeSources[i] controller |
Two rules make the layout work in every scope — the unified "All" view, a
single source picked in the selector, and a lockedScope page — from one
implementation:
- Render the count on its own row, directly above the grid, unconditionally.
page.totalCountis the cumulative in-scope total, so it's meaningful whether one source or all of them are in scope. Don't gate it on scope. - Show per-source filter chrome only when a single source is in scope. Under
"All", heterogeneous per-source filters can't be combined into one panel, so
collapse to just the count. Detect this with
handle.scope !== ALL_SCOPE(thenhandle.inScopeSources[0]is the sole controller).
import {
useSearch,
SearchBar,
ScopeSelector,
SortControl,
ActiveFilters,
FilterProvider,
FilterResetButton,
DefaultFilterPanel,
PaginationControls,
ALL_SCOPE,
} from "@salesforce/ui-bundle-template-feature-react-search";
function UnifiedSearch({ restrictTo }: { restrictTo?: string }) {
// config.pagination = { mode: "merged", mergeOrder: "proportional",
// pageSize: 6, pageSizeOptions: [6, 12, 24] } — see §9 and §11.
const handle = useSearch(config, { lockedScope: restrictTo });
const { globalPagination: page, mergedResults } = handle;
const singleSourceInScope = handle.scope !== ALL_SCOPE;
const soleController = singleSourceInScope ? handle.inScopeSources[0] : undefined;
return (
<div className="max-w-6xl mx-auto px-4 py-6 space-y-6">
{/* Row 1 — search box + scope selector (hidden when the scope is locked). */}
<div className="flex flex-wrap items-center gap-3">
<SearchBar value={handle.q} onChange={handle.setQ} placeholder="Search…" />
{!handle.scopeLocked && (
<ScopeSelector config={config} scope={handle.scope} onScopeChange={handle.setScope} />
)}
</div>
{/* Row 2 — the sole source's filters + sort, only under a single scope. */}
{singleSourceInScope && soleController && (
<FilterProvider
filters={soleController.filters.active}
onFilterChange={soleController.filters.set}
onFilterRemove={soleController.filters.remove}
onReset={() =>
soleController.filters.active.forEach((f) => soleController.filters.remove(f.field))
}
>
<div className="rounded-md border p-3 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Filters</h3>
<FilterResetButton size="sm" />
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<DefaultFilterPanel source={soleController.config} />
</div>
{soleController.config.sortBy && (
<SortControl
configs={soleController.config.sortBy}
sort={soleController.sort.current}
onSortChange={soleController.sort.set}
/>
)}
<ActiveFilters
filters={soleController.filters.active}
onRemove={soleController.filters.remove}
/>
</div>
</FilterProvider>
)}
{/* Count — its own row, directly above the grid, in EVERY scope. */}
{!handle.loading && !handle.error && (
<p className="text-sm text-muted-foreground" role="status">
{page.totalCount} total result{page.totalCount === 1 ? "" : "s"}
</p>
)}
{/* Merged, cumulatively-paginated card grid. Each item knows its source. */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{mergedResults.map(({ sourceKey, node }, i) => (
<MyCard key={i} sourceKey={sourceKey} node={node} />
))}
</div>
{/* One shared pager over the cumulative set. Passing pageCount + onGoToPage
renders numbered page buttons; omit them for prev/next only. */}
{page.totalCount > 0 && (
<PaginationControls
pageIndex={page.pageIndex}
pageCount={page.pageCount}
hasNextPage={page.hasNextPage}
hasPreviousPage={page.hasPreviousPage}
pageSize={page.pageSize}
pageSizeOptions={page.pageSizeOptions}
onNextPage={page.goToNextPage}
onPreviousPage={page.goToPreviousPage}
onGoToPage={page.goToPage}
onPageSizeChange={page.setPageSize}
disabled={handle.loading || !!handle.error}
/>
)}
</div>
);
}Notes:
MyCardswitches onsourceKeyto pick the right card component per object type (see §8). Read node fields with the exportedfieldValue(), or render a<DefaultResultRow node source>for a built-in row.- Filter primitives are wired through
FilterProvider, not props — anything inside it (DefaultFilterPanel, or individualTextFilter/SelectFilterfrom §6) reads and writes that source's filter state automatically. - The pager needs
mode: "merged"inconfig.paginationfor the "shows exactlypageSizecards,goToPagejumps anywhere" behavior above. Under the default"per-source"mode the same widget still works but paginates each source on its own cursor (see the §9 mode table). - This same component serves single-object pages — pass
restrictTo(a source key) aslockedScope;scopeLockedbecomestrue, the selector hides, andmergedResults/pagecollapse to that one source. The count and pager code paths are unchanged.
11. Choosing a merge order (sequential vs proportional)
config.pagination.mergeOrder decides how "merged"-mode results from
different sources are ordered within each page. It only matters when 2+ sources
are in scope; it's ignored in "per-source" mode and under a single scope.
| Order | Behavior | Reach for it when… |
| ------------------------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| "sequential" (default) | All of source 1, then all of source 2, … in config order. | One source is primary and the rest are secondary — e.g. Accounts first, then supporting Contacts/Opportunities. |
| "interleaved" | Round-robin, one per source per round — every source ranks equally. | You want a fair sample of each type up front regardless of how many hits each has (e.g. a "spotlight" grid). |
| "proportional" | Each source spread across the list in proportion to its result count. | You want each page to mirror the overall mix — larger result sets appear more often, matching their share. |
Sequential — front-loads by config order. The first source fills the early
pages; later sources only appear once the earlier ones are exhausted. Best when
there's a natural priority. With pageSize: 6 and Accounts (100 hits) before
Contacts (20), page 1 is 6 Accounts, and Contacts don't surface until ~page 17.
const config: SearchConfig = {
pagination: {
mode: "merged",
mergeOrder: "sequential",
pageSize: 6,
pageSizeOptions: [6, 12, 24],
},
sources: [
{ kind: "sobject", key: "accounts" /* … */ }, // leads
{ kind: "sobject", key: "contacts" /* … */ }, // follows
{ kind: "sobject", key: "opportunities" /* … */ }, // last
],
};Proportional — every page reflects the mix. Each source is spread evenly by
its totalCount, so a source with 100 hits appears ~5× as often as one with 20.
With the same 100 Accounts / 20 Contacts and pageSize: 6, roughly every page is
5 Accounts + 1 Contact — the 5:1 ratio of the whole result set — so a user sees
all types from page 1 without one type monopolizing the front.
const config: SearchConfig = {
pagination: {
mode: "merged",
mergeOrder: "proportional",
pageSize: 6,
pageSizeOptions: [6, 12, 24],
},
sources: [
{ kind: "sobject", key: "accounts" /* … */ },
{ kind: "sobject", key: "contacts" /* … */ },
{ kind: "sobject", key: "opportunities" /* … */ },
],
};Proportional reads each source's
totalCountto compute its share, so a source that omitstotalCountfalls back to its fetched node count for weighting. Page boundaries stay exact under all three orders — only the order within the cumulative list changes, never which items belong to pageP.
URL and state conventions
State is reflected in the URL (debounced 300 ms), so searches are bookmarkable and shareable:
| URL param | Meaning |
| ------------------------------- | --------------------------------------------- |
| q | Global search term (broadcast to all sources) |
| scope=<key> | Narrow to one source (omitted = "all") |
| s.<key>.f.<field>=<value> | Single-value filter for a source |
| s.<key>.f.<field>.min=<value> | Range/numeric/date lower bound |
| s.<key>.f.<field>.max=<value> | Range/numeric/date upper bound |
| s.<key>.sort=<field> | Source-specific sort field |
| s.<key>.dir=ASC\|DESC | Source-specific sort direction |
| s.<key>.ps=<n> | Source-specific page size |
| s.<key>.page=<n> | Source-specific 1-based page index |
Changing q resets every source's pagination; changing a source's filter or
sort resets that source's pagination only. Cursors back the prev/next paging
but are not persisted to the URL (only the page index is).
When restrictTo / lockedScope is set, ?scope= is not written — the
route already implies the source.
Advanced: CMS search
This is the part most likely to be missed — read all of it before assuming
CMS search "just works" after adding a cms source to your config.
Enabling CMS search (API v68 gate)
- API version requirement. Search on CMS content is supported only when the
bundle is built against API version 68.0 or greater. The build-time
version (
__SF_API_VERSION__, injected from the resolved org's API version) is the version every SDK request is actually issued at, so it is the gate.
UIBundle id resolution and the per-fetch gate
- No static id in config. The UIBundle id is resolved at runtime by
adapters/cms/searchChannel.ts::getUIBundleId(), which calls@salesforce/platform-sdk'sgetCurrentApp()and readsidentity.bundleId— the UIBundle record id (a9YE...id). This is only populated when running on the WebApp surface (the runtime injectsSFDC_ENV, perpackages/sdk/platform-sdk/src/core/app.ts). Locally, or on any non-WebApp surface,bundleIdisundefined/"", and the CMS source is skipped with no error — by design. - The gate:
hooks/useSearch.tsresolvesgetUIBundleId()andgetOrgSupportsCmsSearch()once per fetch and, for the CMS source, includes the CMS request only when BOTH pass:isConfiguredUIBundleId(uiBundleId)(adapters/cms/searchChannel.ts) and the build API-version check (above).isConfiguredUIBundleIdvalidates the9YEshape (UI_BUNDLE_ID_PATTERN = /^9YE[a-zA-Z0-9]{12,15}$/) — an empty or malformed id, or a build below v68, causes the CMS source to be silently dropped from the request (no per-source error banner), not sent to the server. - CMS UI surfaces hide on a gated build too. When CMS search is skipped for
the build-version reason, its scope entry and result section would only ever
be empty, so both are hidden using the SAME gate as the fetch skip —
isCmsSearchSupported()(the synchronous core ofgetOrgSupportsCmsSearch(), inadapters/cms/api/orgApiVersionService.ts). Two minimal guards read it:ScopeSelectorskips the CMS<SelectItem>("Content") andSearchResultsskips the CMSSourceSection("Content" heading + empty state). Both are safe during render because the gate reads only the build-time API version. (The runtimebundleIdgate above is not build-constant — it can differ per surface — so it stays a per-fetch skip and does not hide the UI; a CMS source skipped only for a missingbundleIdstill shows its section, empty.)
uibundleIds vs channelIds
- The query field:
adapters/cms/cmsQueryFragment.tssends the resolved$UIBundleIdintosearchIdentifiers.uibundleIds(NOTchannelIds) onmanaged_content.search.searchContentInChannels. Content-type filtering ($cmsContentTypeFQNs) is omitted on the bootstrap call and supplied once discovery completes.- Why
uibundleIds(evidence from core).ContentSearchIdentifiersInputhas three id-space fields —channelIds(0apManaged Content channel ids),siteIds, anduibundleIds— and the9YEUIBundle record id belongs only inuibundleIds. In core (core-2206/core-266-public): the schemaui-services-private/.../graphql-schemas/mcontent-search.graphqlsdocumentsuibundleIdsas "Each UI bundle ID is resolved to its associated Managed Content channel(s)"; the resolvermcontent-impl/.../ManagedContentGraphQLSearchServiceImpl.resolveChannelIdsFromSitesAndUiBundles()resolves each id viagetManagedContentChannelsByTarget(uibundleId)(the id is the channel's TargetEntityId, i.e. a UIBundle record id); the UDDlwr-udd/.../UIBundle.entity.xmlsetskeyPrefix="9YE"; and the unit testManagedContentGraphQLSearchServiceImplTest.testUiBundleId_resolvesToChannelIds_andIsSearched()passes a9YE…id intouibundleIdsand asserts it resolves (W-23364628).channelIdsvalues are validated as channel/site ids and never run through the target-entity resolver, so a9YEid sent there fails with"9YE… isn't a valid managed content channel ID or a site ID"— the exact error this wiring fixes. (Server-side, resolution ofuibundleIds/siteIdsis gated by the core feature flagisAllowTargetEntityIdsEnabled().)
- Why
Content-type discovery
- Content-type discovery: after the first CMS result returns,
adapters/cms/channelResolver.ts::resolveChannelId()extracts the0apmanaged-content-channel id fromnodes[0].managedContentChannelDeliveryDetails[0].managedContentChannelDetails.id.adapters/cms/hooks/useSearchableContentTypes.ts(session-cached) then fetchesGET /connect/cms/channels/{channelId}/searchable-content-typesto populate the scope dropdown's per-content-type entries. Note: this0apid is a different id space from the9YEUIBundle id above — don't confuse the two when debugging.
Seeding content
- Seeding content: the resolved
bundleIdonly surfaces content published to the UIBundle's own channel (WebApp type), so CMS content must be published there — not to the Experience Site COMMUNITY channel. Content published to the wrong channel returns zero results even when the id/gate/query wiring is correct, so empty CMS results with everything else in place usually points at the publishing target rather than the code. - Mounting: add a
{ "kind": "cms", "key": "...", "label": "..." }entry toconfig.sources(inconfig.json, or a customSearchConfigobject at runtime), then render<Search config={config} />somewhere routed — see the next section.
Mounting GlobalSearchBox + <Search> at /search
GlobalSearchBox(components/GlobalSearchBox.tsx) is a pure launcher — a text input + button that callsnavigate('/search?q=...')on submit. It renders no results itself.GlobalSearchBoxonly routes to/search; you must mount<Search>at that route for results to render. In a template that ships one,routes.tsxdoes exactly that:path: "search"renders<GlobalSearch config={config} .../>(GlobalSearchisSearchaliased on import). If an app drops inGlobalSearchBoxwithout also mounting<Search>(or a custom results UI built onuseSearch) at the route it navigates to, searches will appear to do nothing.
For <Search>'s own props (restrictTo, renderResult / renderFilters,
showScopeSelector, etc.), see Props above.
Internal structure
config.json(co-located with the source in a consuming feature) declaressources: SourceConfig[]— a discriminated union onkind— plus an optionalpaginationblock. The shipped default has 3"sobject"sources (accounts,contacts,opportunities) and one"cms"source (key: "content").loadConfig.tsre-exportsconfig.json, typed asSearchConfig. For a pure config change (add/remove a source, tweak fields), editconfig.jsondirectly — no code changes required.types.tsis the source of truth forSourceConfig(SObjectSourceConfig | CmsSourceConfig) andSearchConfig.adapters/registry.tsmapskind→ adapter (sobject → sObjectAdapter,cms → cmsAdapter). Adding a new backendkindmeans registering an adapter here plus extending theSourceConfigunion intypes.ts— out of scope for typical customization; most tasks only touchconfig.json.
Public API
Everything is exported from
