A reusable Next.js App Router search experience built on the Sitecore Search Cloud SDK — search, keyphrase suggestions, facets, sorting, pagination, empty/error/loading states, and Search event tracking, with a clean separation between Sitecore Search configuration and UI.
This is a reference implementation, not just a demo: the goal is to show how to structure a maintainable Sitecore Search integration in a Next.js app — where SDK calls live, how to keep components testable without the SDK, and how to avoid committing credentials.
Sitecore's current docs for connecting Search to a Next.js app (XM Cloud / SitecoreAI) point to the Cloud SDK (@sitecore-cloudsdk/search, @sitecore-cloudsdk/events, @sitecore-cloudsdk/core) — SearchWidgetItem, getWidgetData, Context, facets, sorting, and click/view events. But most of the docs' code samples are single-file, "paste this into page.tsx" snippets. There isn't a reference showing how those pieces fit together in a real app: where the SDK calls live, how facets/sort/pagination/suggestions compose, how to keep components testable without a live Sitecore Search tenant, and how to avoid ever committing credentials.
sitecore-search-nextjs-search-experience/
│
├── app/
│ ├── layout.tsx # mounts CloudSDKProvider once for the whole app
│ ├── page.tsx
│ └── search/
│ └── page.tsx # server component shell, renders <SearchExperience />
│
├── components/
│ ├── CloudSDKProvider.tsx # initializes the Cloud SDK once, client-side
│ └── search/
│ ├── SearchBox.tsx # input + debounced suggestions dropdown
│ ├── SearchFacets.tsx # facet checkboxes
│ ├── SearchSort.tsx # sort dropdown
│ ├── SearchPagination.tsx
│ ├── SearchResults.tsx # loading / error / empty / results states
│ └── SearchExperience.tsx # owns state, wires data + tracking to the above
│
├── lib/
│ └── sitecore-search/
│ ├── client.ts # reads & validates env config — the ONLY place that reads process.env
│ ├── search.ts # the ONLY module that imports @sitecore-cloudsdk/search
│ ├── events.ts # the ONLY module that imports @sitecore-cloudsdk/events
│ ├── mock-data.ts # local demo data, used only when Search isn't configured
│ └── types.ts # app-owned TypeScript models, decoupled from SDK response shapes
│
├── tests/
│ ├── lib/ # config validation, mock data, and SDK-mapping tests (SDK is mocked)
│ └── components/ # presentational component tests
│
├── .env.example
├── README.md
├── package.json
├── tsconfig.json
└── LICENSE
- Every component under
components/search/is presentational. They take data and callbacks as props and know nothing about Sitecore Search,fetch, or the SDK. That's what makesSearchBox.test.tsx,SearchFacets.test.tsx, andSearchPagination.test.tsxpossible without any SDK setup. components/search/SearchExperience.tsxis the only component that toucheslib/sitecore-search. It owns all search state (keyphrase, page, selected facets, sort) and coordinates data fetching + event tracking.lib/sitecore-search/search.tsis the only file that imports@sitecore-cloudsdk/search. It builds theSearchWidgetItemrequest and maps the rawSearchEndpointResponseinto this repo's ownSearchExperienceResulttype. If Sitecore changes the SDK's response shape, or you swap to a different search backend entirely, this is the one file that changes.lib/sitecore-search/events.tsis the only file that imports@sitecore-cloudsdk/eventsand the SDK's search event functions (widgetItemClick,widgetFacetClick,widgetSuggestionClick,entityView,pageView).lib/sitecore-search/client.tsis the only file that readsprocess.envfor Sitecore Search settings, and validates it, throwing a clear error naming exactly which variable is missing.
npm install
npm run devWithout any configuration, /search runs on local demo data (lib/sitecore-search/mock-data.ts) so you can explore the UI immediately — no Sitecore Search tenant required.
- Copy
.env.exampleto.env.local. - Fill in the values from Sitecore Cloud Portal → your project → Admin → API Access (Edge context ID, site name) and the Search Customer Engagement Console (CEC) → Developer Resources (widget ID, entity, suggestion block name).
- Restart
npm run dev.lib/sitecore-search/client.tsdetects the configuration andSearchExperienceautomatically switches from mock data to live Sitecore Search queries.
No API credentials are committed to this repo — .env.local is git-ignored, and .env.example contains only placeholders.
| Requirement | Where |
|---|---|
| Search | SearchBox (input) → SearchExperience → lib/sitecore-search/search.ts (SearchWidgetItem.query) |
| Suggestions | SearchBox suggestions dropdown, backed by the SDK's suggestion option and a configured Suggestion Block (NEXT_PUBLIC_SITECORE_SEARCH_SUGGESTION_BLOCK) |
| Facets | SearchFacets, backed by SearchWidgetItem.facet |
| Sorting | SearchSort, backed by SearchWidgetItem.sort |
| Pagination | SearchPagination, backed by SearchWidgetItem.offset / .limit |
| Loading / empty / error states | SearchResults |
| Event tracking | lib/sitecore-search/events.ts — page view, result click, facet click, suggestion click, entity view |
| TypeScript models | lib/sitecore-search/types.ts |
| Config/UI separation | see above |
| No committed credentials | .env.example + .gitignore |
A few things the SDK does differently than you might expect from a quick skim of the docs — worth knowing if you extend this:
- Sort responses don't include direction.
getWidgetDatareturns sort choices as{ name, label }only; ascending/descending is something you request. This repo expands each response choice into a labeled "(A–Z)" / "(Z–A)" pair — seemapSortChoicesinlib/sitecore-search/search.ts. - Facet values use
text, notlabel, in the raw response (FacetValueResponse.text) — mapped to this repo'slabelfield. - Suggestions come back keyed by Suggestion Block name:
{ [blockName]: Array<{ text, freq }> }, not a flat array. You request one block viasuggestion: [{ name, max }]on the sameSearchWidgetItem— there's no separate suggestions widget. contentitems in the response are typedunknown[]by the SDK, since their shape depends entirely on your Sitecore Search entity configuration.mapContentItemreads a handful of conventional fields defensively and keeps the rest inattributes.- Event functions take one structured params object, not positional IDs — e.g.
widgetItemClick({ pathname, widgetId, entity, itemPosition, request }), whereentityandrequestare themselves structured objects. Seelib/sitecore-search/events.ts.
npm test # run once
npm run test:watch
npm run typecheck
npm run build # full Next.js production buildEvery SDK-touching module is tested with the SDK mocked via vi.mock('@sitecore-cloudsdk/search/browser', ...), so the suite runs with no network access and no real Sitecore Search credentials. Presentational components are tested with Testing Library, independent of the SDK entirely.
Issues and PRs welcome. Please add or update tests for any behavior change, and run npm test, npm run typecheck, and npm run build before submitting.