npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@salesforce/ui-bundle-template-feature-react-search

v12.4.5

Published

[Beta] Configuration-driven multi-source search for UI Bundles

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 react 19 and react-router 7. 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

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 via createDataSDK().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-search

Then 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 UI

Props

| 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 Mounting GlobalSearchBox + <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 configconfig.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:

  1. 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.
  2. 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 an AccountDetail page/component that reads the :id param 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: routePattern is a path template with :token placeholders resolved per result.
    • sObject rows (components/results/DefaultResultRow.tsx, resolveRoute(routePattern, node, idField)): :fieldName tokens are substituted from the record's field values, and a bare :id maps to the source's idField (default Id). Each token is a single field name — unlike searchableFields, routePattern tokens do not support dot-paths (:Owner.Name resolves only :Owner and leaves the row non-clickable via the runtime-null fallback below). For a nested value, expose it as a flat field or use renderResult. The shipped accounts source uses "/accounts/:id".
    • CMS rows (components/results/CmsResultRow.tsx): tokens are :id (the managedContentId), :key, and :contentType; when no routePattern is set it falls back to /content/:contentType/:id.
    • Runtime-null fallback: if any token resolves to null for a record, that row falls back to a plain, non-clickable layout rather than emitting a broken link. Make sure tokenized fields are present in displayFields.
  • 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 the experience-ui-bundle-salesforce-data-access skill — do not hand-roll fetch); CMS detail: fetch the content item by managedContentId through 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-generate skill (its page types include "detail view"; record data is fetched via the platform Data SDK per the experience-ui-bundle-salesforce-data-access skill).
    • CMS content-detail page — generate it with the experience-cms-content-render skill. That skill owns CMS content fetch + render (the delivery API, contentKey/contentType, RichText, and image resolution) and takes precedence over experience-ui-bundle-frontend-generate for the rendering portion — the UI skill owns only the page/layout shell. Its Detail Page branch writes src/pages/<type>/<PageName>.tsx and 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.)

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-made GlobalSearchBox launcher 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 page P, every in-scope source fetches (P + 1) × pageSize rows from its front; concatenating them in config order and slicing [P × pageSize, (P + 1) × pageSize) yields the precise page, because the first (P + 1) × pageSize items 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. goToNextPage advances only sources that still have data; a source that runs out is left behind and drops out of mergedResults on later pages (its final page was already shown). pageCount is the max of ceil(totalCount / pageSize) across in-scope sources, and goToPage honours only adjacent pages. For fully independent pagers, render one <SourceSection> (or PaginationControls) per handle.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:

  1. Render the count on its own row, directly above the grid, unconditionally. page.totalCount is 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.
  2. 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 (then handle.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:

  • MyCard switches on sourceKey to pick the right card component per object type (see §8). Read node fields with the exported fieldValue(), or render a <DefaultResultRow node source> for a built-in row.
  • Filter primitives are wired through FilterProvider, not props — anything inside it (DefaultFilterPanel, or individual TextFilter / SelectFilter from §6) reads and writes that source's filter state automatically.
  • The pager needs mode: "merged" in config.pagination for the "shows exactly pageSize cards, goToPage jumps 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) as lockedScope; scopeLocked becomes true, the selector hides, and mergedResults / page collapse 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 totalCount to compute its share, so a source that omits totalCount falls 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 page P.


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's getCurrentApp() and reads identity.bundleId — the UIBundle record id (a 9YE... id). This is only populated when running on the WebApp surface (the runtime injects SFDC_ENV, per packages/sdk/platform-sdk/src/core/app.ts). Locally, or on any non-WebApp surface, bundleId is undefined/"", and the CMS source is skipped with no error — by design.
  • The gate: hooks/useSearch.ts resolves getUIBundleId() and getOrgSupportsCmsSearch() 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). isConfiguredUIBundleId validates the 9YE shape (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 of getOrgSupportsCmsSearch(), in adapters/cms/api/orgApiVersionService.ts). Two minimal guards read it: ScopeSelector skips the CMS <SelectItem> ("Content") and SearchResults skips the CMS SourceSection ("Content" heading + empty state). Both are safe during render because the gate reads only the build-time API version. (The runtime bundleId gate 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 missing bundleId still shows its section, empty.)

uibundleIds vs channelIds

  • The query field: adapters/cms/cmsQueryFragment.ts sends the resolved $UIBundleId into searchIdentifiers.uibundleIds (NOT channelIds) on managed_content.search.searchContentInChannels. Content-type filtering ($cmsContentTypeFQNs) is omitted on the bootstrap call and supplied once discovery completes.
    • Why uibundleIds (evidence from core). ContentSearchIdentifiersInput has three id-space fields — channelIds (0ap Managed Content channel ids), siteIds, and uibundleIds — and the 9YE UIBundle record id belongs only in uibundleIds. In core (core-2206/core-266-public): the schema ui-services-private/.../graphql-schemas/mcontent-search.graphqls documents uibundleIds as "Each UI bundle ID is resolved to its associated Managed Content channel(s)"; the resolver mcontent-impl/.../ManagedContentGraphQLSearchServiceImpl.resolveChannelIdsFromSitesAndUiBundles() resolves each id via getManagedContentChannelsByTarget(uibundleId) (the id is the channel's TargetEntityId, i.e. a UIBundle record id); the UDD lwr-udd/.../UIBundle.entity.xml sets keyPrefix="9YE"; and the unit test ManagedContentGraphQLSearchServiceImplTest.testUiBundleId_resolvesToChannelIds_andIsSearched() passes a 9YE… id into uibundleIds and asserts it resolves (W-23364628). channelIds values are validated as channel/site ids and never run through the target-entity resolver, so a 9YE id 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 of uibundleIds/siteIds is gated by the core feature flag isAllowTargetEntityIdsEnabled().)

Content-type discovery

  • Content-type discovery: after the first CMS result returns, adapters/cms/channelResolver.ts::resolveChannelId() extracts the 0ap managed-content-channel id from nodes[0].managedContentChannelDeliveryDetails[0].managedContentChannelDetails.id. adapters/cms/hooks/useSearchableContentTypes.ts (session-cached) then fetches GET /connect/cms/channels/{channelId}/searchable-content-types to populate the scope dropdown's per-content-type entries. Note: this 0ap id is a different id space from the 9YE UIBundle id above — don't confuse the two when debugging.

Seeding content

  • Seeding content: the resolved bundleId only 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 to config.sources (in config.json, or a custom SearchConfig object 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 calls navigate('/search?q=...') on submit. It renders no results itself.
  • GlobalSearchBox only routes to /search; you must mount <Search> at that route for results to render. In a template that ships one, routes.tsx does exactly that: path: "search" renders <GlobalSearch config={config} .../> (GlobalSearch is Search aliased on import). If an app drops in GlobalSearchBox without also mounting <Search> (or a custom results UI built on useSearch) 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) declares sources: SourceConfig[] — a discriminated union on kind — plus an optional pagination block. The shipped default has 3 "sobject" sources (accounts, contacts, opportunities) and one "cms" source (key: "content").
  • loadConfig.ts re-exports config.json, typed as SearchConfig. For a pure config change (add/remove a source, tweak fields), edit config.json directly — no code changes required.
  • types.ts is the source of truth for SourceConfig (SObjectSourceConfig | CmsSourceConfig) and SearchConfig.
  • adapters/registry.ts maps kind → adapter (sobject → sObjectAdapter, cms → cmsAdapter). Adding a new backend kind means registering an adapter here plus extending the SourceConfig union in types.ts — out of scope for typical customization; most tasks only touch config.json.

Public API

Everything is exported from