Skip to content

Writing

Sitecore Search Widgets in 2026 — What I’d Rebuild From Our 2024 SDK Series

Muhammad Ali Khan · 27 Oct 2026 · 08 Mins read

Sitecore Search Widgets in 2026 — What I’d Rebuild From Our 2024 SDK Series

In 2024 I wrote a short series on the Sitecore Search JS SDK and then a geospatial follow-up. Part 1, in March, covered the React and UI packages, WidgetsProvider, and useSearchResults. Part 2, in April, was about pulling the Search SDK starter kit into a Sitecore Next.js app: a source in the Customer Engagement Console, a suggestions block named title_context_aware, sorting options featured_desc and featured_asc, and the preconfigured widgets the starter kit expects. In May I broke the starter-kit UI into separate facet, pagination, sort, and result components, and added show-more on long facet lists. In October I indexed a geo coordinate and queried it.

Two years later I still stand by the shape. Hooks for results, preview search, and recommendations. A widget id from CEC. Facets as their own component. What I would not carry forward is the starter kit as a dependency inside a SitecoreAI Next.js app, or a single ArticleModel that tries to be a document, a store, and an answer. This is the rebuild I would do now, against the SDK docs as they stand and against the Questions and Answers API that was not the subject of those 2024 posts.


What I would keep from the 2024 series

The provider setup is still the front door. @sitecore-search/react and @sitecore-search/ui are still the packages, and the app still wraps the tree in WidgetsProvider with the environment, customer key, and API key. Those values are still environment variables. I have not found a reason to get clever about that.

useSearchResults still takes page, keyphrase, selectedFacets, itemsPerPage, and sortType, and it still hands back actions for page changes, facet clicks, sort changes, item clicks, and clearing filters. The actions are not optional polish. onItemClick is how the widget records the click that CEC uses for analytics. A pretty list that navigates with a plain anchor and never calls onItemClick looks done and starves the optimization features of data. That was easy to miss in a starter-kit demo. I would make it a review comment in 2026.

usePreviewSearch still belongs in the header, with a suggestionsList. The docs now spell out keyphrase_fallback on a suggestion entry: when the keyphrase has no results, Search can use the first suggestion from that block as an alternate keyword. I would turn that on for the title suggestion block and leave it off for trending categories. Fallback on a trending block substitutes a popular phrase for the user's words, which feels helpful in a demo and wrong on a support site.

useRecommendation is still a separate widget type, with onItemClick and a content array. I kept it separate in 2024 by accident of the starter kit's page list. I would keep it separate on purpose now. A recommendation row that secretly calls useSearchResults will inherit the search page's facets and sort, and then everyone will argue about why "related articles" changes when the user filters the main grid.

The CEC prerequisites from Part 2 are still prerequisites. A source, a suggestions block whose name matches the string in code, and sorting options the widget requests by name. featured_desc only works if that option exists and the source has been reindexed after the option was added. I have rebuilt this bug twice. The hook is innocent.

Geospatial stays. A coordinate attribute of geo type, queried by a widget that knows it is looking at places. I would not add latitude and longitude strings to the article model from Part 1 and call that a location search. The 2024 geo post existed because the types are different. That part aged well.


What I would throw out

The starter kit's styled-components skin. Part 2 installed styled-components because the starter kit did. Inside a Sitecore Next.js app we already have a design system, a layout, and a header. The starter kit is a reference implementation. I fork it to read it. I do not ship its providers, its page chrome, or its CSS reset next to XM Cloud or SitecoreAI layout components. The May 2024 post was already moving this direction by splitting facets and results into separate components. I would finish the job and delete the kit's shell.

Hard-coded rfkIds. The starter kit's README still talks about rfkid_7 for results, rfkid_6 for preview search, and an experimental rfkid_qa. Those ids are account configuration. They are not a protocol. In our apps they move between non-production and production CEC environments. I read them from configuration.

One TypeScript type for every widget. Part 1's ArticleModel listed title, name, subtitle, description, content_text, image_url, and source_id as optional strings. That was a fair sample. It becomes a problem when the Q&A payload, which returns question, answer, context, and document_id, gets stuffed into the same type with a cast. I give each widget a model that matches the attributes it requested. If a field is not extracted, it is not on the type.

Q&A inside the results component. This is the 2026 change. Questions and Answers is its own widget request. The keyphrase has to qualify as a question or a statement, or the API returns an empty answer set on purpose. Mixing that into useSearchResults means a normal keyword search renders an empty "AI answer" box and the user thinks search is down. I render answers from a dedicated call, or I render nothing.

Show-more as the only facet strategy. The May post's control is still good when a facet has a few dozen values. Sitecore's facet docs set a default max of 10, warn that values above 50 get expensive, and cap the max at 100. I lower max and filter the values we actually returned. Show-more sits on top of that limit.


The shape I would build

Three client widgets and one server-side answer fetch. The client widgets stay on @sitecore-search/react because they are interactive and they need the click actions. The answer fetch stays on the server because that is where the Cloud SDK search package documents QuestionsAnswersOptions, and because I do not want to widen the browser's access to the domain.

export const searchWidgetIds = {
  results: process.env.NEXT_PUBLIC_SEARCH_RESULTS_RFK_ID ?? '',
  preview: process.env.NEXT_PUBLIC_SEARCH_PREVIEW_RFK_ID ?? '',
  recommendation: process.env.NEXT_PUBLIC_SEARCH_RECOMMENDATION_RFK_ID ?? '',
  questions: process.env.SEARCH_QA_RFK_ID ?? '',
};

export type ArticleHit = {
  id: string;
  title?: string;
  url?: string;
  description?: string;
  image_url?: string;
  published_at?: string;
  type?: string;
};

export type PlaceHit = ArticleHit & {
  coordinates?: string;
};

SEARCH_QA_RFK_ID is deliberately not NEXT_PUBLIC_. The server route reads it. The browser receives HTML. PlaceHit exists so a location widget cannot compile against an article that has no coordinates. That is the whole geospatial lesson, expressed as a type.

The results widget is the Part 1 hook, with the pieces I used to leave as an exercise.

import type { SearchResultsStoreState } from '@sitecore-search/react';
import {
  WidgetDataType,
  useSearchResults,
  widget,
} from '@sitecore-search/react';
import type { ArticleHit } from './searchWidgetIds';

type State = SearchResultsStoreState;

const SearchResultsView = () => {
  const {
    widgetRef,
    actions: { onItemClick, onPageNumberChange, onFacetClick, onClearFilters },
    queryResult: { isLoading, isFetching, error, data },
  } = useSearchResults<ArticleHit, State>({
    query: (query) => query,
    state: {
      sortType: 'featured_desc',
      page: 1,
      itemsPerPage: 12,
      keyphrase: '',
    },
  });

  const articles = data?.content ?? [];
  const total = data?.total_item ?? 0;
  const pending = isLoading || isFetching;

  return (
    <div ref={widgetRef}>
      {pending && <p>Searching…</p>}
      {error && <p role="alert">Search is unavailable. Try again in a moment.</p>}
      {!pending && !error && total === 0 && (
        <p>No pages matched. Try a different phrase or clear the filters.</p>
      )}
      <ul>
        {articles.map((article, index) => (
          <li key={article.id}>
            <a
              href={article.url}
              onClick={(event) => {
                event.preventDefault();
                onItemClick({ id: article.id, index, sourceId: article.id });
                if (article.url) {
                  window.location.assign(article.url);
                }
              }}
            >
              {article.title}
            </a>
            {article.published_at && <time dateTime={article.published_at}>{article.published_at}</time>}
          </li>
        ))}
      </ul>
      <button type="button" onClick={() => onPageNumberChange({ page: 2 })}>
        Next
      </button>
      <button type="button" onClick={onClearFilters}>
        Clear filters
      </button>
      {/* FacetList from the May 2024 split lives here and calls onFacetClick.
          It does not fetch its own results. */}
    </div>
  );
};

export const SearchResultsWidget = widget(
  SearchResultsView,
  WidgetDataType.SEARCH_RESULTS,
  'content'
);

A few notes, because this kind of sample hides the decisions. query: (query) => query is the documented way to accept the widget query unchanged. I add filters in that function when a page has a hard filter, such as type equals article, and I do not bake the filter into the React tree. The page-2 button is a sketch. The real pagination component from the May post should call onPageNumberChange with the page the visitor picked. onItemClick's exact payload follows the SDK version you install. I check the type in node_modules when I bump @sitecore-search/react rather than assuming the 2024 signature survived unchanged. The intent will not change: the click has to go through the action before navigation.

Preview search keeps the fallback flag on the suggestion block Part 2 already required us to create:

state: {
  suggestionsList: [
    { suggestion: 'title_context_aware', max: 5, keyphrase_fallback: true },
  ],
  itemsPerPage: 6,
}

If CEC names that block something else, I rename the code. I do not create a second block called title_context_aware just so a blog sample runs.


Where the answer widget sits

I do not mount Q&A as a widget() from the results package. The documented Next.js path is the Cloud SDK search package, and the documented request is a widget item with a questions object. The server route takes the visitor's question, calls Search, and returns a view model the UI can render without knowing the API.

type AnswerCard = {
  question: string;
  answer: string;
  documentId: string;
  url?: string;
};

export function toAnswerCard(hit: {
  question?: string;
  answer?: string;
  document_id?: string;
  url?: string;
}): AnswerCard | null {
  if (!hit.answer || !hit.document_id || !hit.question) {
    return null;
  }

  return {
    question: hit.question,
    answer: hit.answer,
    documentId: hit.document_id,
    url: hit.url,
  };
}

url is only present if we extracted it and asked for it back. document_id without a URL is still useful in the logs and useless on the page. The UI requirement I would not compromise on: the answer and the source link render together, or the answer does not render. Related questions, the related_questions array, can sit under the card as a "people also asked" list. They are a different control from the keyword suggestions in preview search, and I do not merge the lists.

Rules, boosts, and bury settings stay in CEC unless a page has a reason to override them per request. The SDK exposes a rule option on the Q&A request. I use it for a hard filter such as language or type. I do not use it to reimplement the global textual-relevance weights in every call. Those weights are domain configuration. Scattering them through React components is how two pages start disagreeing about what "relevant" means.


Wiring it into a Sitecore page without a second application

The 2024 Part 2 approach dropped the starter kit beside the JSS app and hoped the layouts would agree. On a SitecoreAI Content SDK app I would do less.

  • WidgetsProvider goes around the search route, or around the shell if the header preview search is global. It does not need to wrap the page builder's editing endpoints.
  • The results widget is a React component registered in the component map like any other rendering, so an author can place "search results" without a custom route. Its rfkId comes from searchWidgetIds.results.
  • Preview search stays in the header component, which is application chrome, not a Sitecore rendering. That matches where it lived in the starter kit and it avoids a placeholder war with the navigation.
  • The answer card is a server-rendered region on the search page. It receives the current keyphrase from the query string. If the keyphrase is two keywords, toAnswerCard returns null and the region stays empty. The results widget underneath still runs.
  • Location search is a different rendering, typed to PlaceHit, pointed at a widget whose CEC configuration knows about the geo attribute. It does not share a facet component with articles unless the facet is actually common, such as language.

Search results are personalized and click-tracked, so I do not statically generate them into a page cache that ignores the query string. The widgets stay in a client boundary, the answer region stays a dynamic server render, and the marketing layout around them keeps the cache tags we already purge on publish. An index update and an HTML purge are still two operations.


The backlog I would actually open

On our current Next.js app the tickets are: move every rfkId into configuration and delete rfkid_7 literals; split ArticleHit from PlaceHit; replace the starter-kit chrome with our header, footer, and facet components; turn on keyphrase_fallback only after the suggestion block name matches CEC; add the server-side Q&A call with the source URL beside any answer; and confirm featured_desc still exists in both CEC environments after the last reindex.

That is a rebuild of the integration, not a rewrite of Search. The 2024 series got the hooks and the CEC setup right. The 2026 work is to stop treating the starter kit as the product, and to give answers their own widget so keyword search can stay good at being keyword search.

Related notes