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

v11.24.4

Published

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

Readme

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

Status: Beta

Configuration-driven, multi-source search for React applications on the Salesforce platform.

🧪 Beta. This feature is in beta. It is usable today, but the API (component props, config schema, and exported primitives) may change in a future release, and breaking changes can land in a minor version. Pin the package version and review the CHANGELOG before upgrading.

⚛️ 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 object 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 — data is fetched via createDataSDK().graphql.query() against the Salesforce uiapi GraphQL bridge. Every source today is an SObject queried through this bridge.
  • 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).


Creating your own config

A SearchConfig is { sources: SObjectSourceConfig[] } plus an optional global pagination block. 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 this section).

Today every source is kind: "sobject", queried through the platform-sdk uiapi GraphQL bridge.

Source fields (SObjectSourceConfig)

| 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. |

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. |

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? }. The type drives which input renders and how the where clause is built:

| 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.

routePattern tokens

When set, the default row becomes a react-router <Link>:

  • :id → the configured idField value.
  • :fieldName → that field's value; supports dot-paths (:Owner.Name).

If any token resolves to null for a record, that row falls back to a plain, non-clickable layout. Make sure tokenized fields are present in displayFields.

routePattern only builds the link — you must supply the destination. This feature turns a result row into a <Link to="/accounts/<id>">; it does not create the page that link points at. For "/accounts/:id" to actually show an account, your app must register a matching route (e.g. <Route path="/accounts/:id" element={<AccountDetail />} />) and implement the AccountDetail page/component that reads the :id param and renders that object's details. Without a corresponding route the link resolves to a blank or not-found page. 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).

A complete source, annotated

{
  "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.


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…
}

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.


Public API

Everything is exported from the package entry point:

// Drop-in + example config
import { Search, config } from "@salesforce/ui-bundle-template-feature-react-search";

// Hooks
import {
  useSearch,
  useAsyncData,
  useDistinctValues,
} from "@salesforce/ui-bundle-template-feature-react-search";

// Composition primitives
import {
  SearchBar,
  SearchResults,
  SourceSection,
  SortControl,
  PaginationControls,
  ScopeSelector,
  ActiveFilters,
  DefaultResultRow,
  DefaultFilterPanel,
} from "@salesforce/ui-bundle-template-feature-react-search";

// Filter inputs + context
import {
  FilterProvider,
  FilterResetButton,
  useFilterField,
  useFilterPanel,
  TextFilter,
  SelectFilter,
  MultiSelectFilter,
  NumericRangeFilter,
  BooleanFilter,
  DateRangeFilter,
  FilterFieldWrapper,
} from "@salesforce/ui-bundle-template-feature-react-search";

// Utilities / lower-level building blocks
import {
  fieldValue,
  buildFilter,
  buildGlobalQueryClause,
  buildOrderBy,
  readSourceParams,
  writeSourceParams,
  GLOBAL_QUERY_KEY,
  buildSearchQuery,
  runSearch,
  fetchDistinctValues,
  ALL_SCOPE,
} from "@salesforce/ui-bundle-template-feature-react-search";

// Types
import type {
  SearchConfig,
  SObjectSourceConfig,
  DisplayField,
  SourceController,
  SourceResult,
  SourcePageInfo,
  SearchHandle,
  SearchScope,
  RenderResultFn,
  FilterFieldConfig,
  FilterFieldType,
  ActiveFilterValue,
  SortFieldConfig,
  SortState,
  PicklistOption,
  SourceRequest,
  SearchQueryPayload,
} from "@salesforce/ui-bundle-template-feature-react-search";

Common mistakes

  • Missing searchableFields — the global q won't match that source. Provide at least one field, or omit the source when q is non-empty.
  • Relationship field without subfields"Owner" as a bare string emits Owner @optional { value displayValue }, which is wrong for a relationship. Use { name: "Owner", subfields: ["Name"] }.
  • Listing idField in displayFields — it's selected automatically; listing it again duplicates the selection.
  • routePattern token not in displayFields — the token can't resolve and every row silently falls back to a non-clickable layout.
  • routePattern without a matching route — the feature only builds the <Link>; it doesn't create the destination. If nothing in your app routes "/accounts/:id" to a detail page, the link lands on a blank/not-found view. Register the route + page yourself (or omit routePattern and render details another way, e.g. a modal).
  • Non-conforming key — must match /^[A-Za-z_][A-Za-z0-9_]*$/; it becomes a GraphQL alias and variable prefix. Invalid keys produce malformed documents.
  • Picklist auto-fetch failing — the aggregate groupBy requires a group-by-able field. For formulas / long text, supply inline options.
  • Overriding whereTypeName / orderByTypeName needlessly — only set these when the schema deviates from ${ObjectName}_Filter / ${ObjectName}_OrderBy. Wrong names cause GraphQL parse errors.