Introduction
There are a lot of reasons to love Headless SXA! Flexibility with your front-end tech stack and wonderful performance benefits, just to name a couple. But there are downsides as well… One of which is the subset of components that come out of the box with Headless SXA. Classic SXA has a very rich set of very useful (and some not so useful) components. One of the more useful features are the Search components – both front end and back end. Not only did you have front-end components like Search Results
, Search Box
, Page Selector
, not to even mention all the Filter
options for facet filtering, but you had back-end components like the ability to define multiple Search Scopes, and various sets of Facets and Sorting options.
I am currently working on a migration of a site to Headless SXA for a customer and we heavily used most of these Search features to build various landing and search pages. One of the unknowns we had at the start of the project was “Which avenue are we going to take to replicate all of our Search functionality?” Unfortunately, our hands were a bit tied… We weren’t able to utilize any third-party solutions so we were going to have to rely on the Solr indexes we were already using. I had figured: “Well, we’re going to need an endpoint for our nextjs app to call, and that endpoint is going to either need to build the solr query directly or call yet another endpoint that does the building and searching…”
When the time came to start implementing the search features on our new headless stack, I had an epiphany: the MVC components from Classic SXA don’t really do anything but “call an endpoint” from javascript, so why can’t I just replicate these calls? That way, I can utilize all of the back-end pieces of the Classic SXA search feature, including Search Scopes and Facets! And you know what, it worked! 😀
Disclosure: This isn’t the most elegant solution in the world, but it fit my customer’s specific need AND I didn’t have to write everything from scratch.
Implementation in Sitecore
The first thing I did was to create a new search scope under the Settings
node of my Headless site. As this is a headless site, the Scopes
node doesn’t exist, so you’ll need to add that. The template is /sitecore/templates/Feature/Experience Accelerator/Search/Settings/Scopes Folder
. While you’re at it, go ahead and add the Facets
node (/sitecore/templates/Feature/Experience Accelerator/Search/Settings/Facets Grouping
) under Settings
as well. I created a News Article Search Scope:
I also created a ListFacet
called news-and-event-date
under the newly created Settings/Facets
node that mapped to the StartDate
field on my News Article
template:
I then created some search-related items to be used as datasources under /Data
:
All of this is pretty standard, Classic SXA stuff to this point. The next thing I needed to do was create a new Json Rendering
for our new Search Results
component, so that it could be inserted onto a page. In my customer’s Feature layer, I created a module called <CustomerName> Search
and added my new Json Rendering
named Search Results
. In this new rendering item, the properties to pay attention to are:
Component Name | SearchResults |
Parameters Template | Templates/Feature/Experience Accelerator/Search/Rendering Parameters/Search Results |
Once this rendering item has been created, add it to the Available Renderings
under /Presentation
, so that it’s available in the Toolbox for use on a page. Go ahead and create a new page and drop this component in a placeholder somewhere. Edit the component properties for Search Results
and configure it this way:
Implementation in Code
Once I had all of these created, I got in to Postman and took one of the existing endpoint calls from the Search Results
component and broke down the fields involved, mapping those fields back to my newly created search scopes, facets, and sorting options.
I also took a look at the results returned from this call in Classic SXA:
If you’ll notice, in each result, there is a field called Html
. In Classic SXA, this holds the output of the rendering variant for each search result item. In a headless world, Rendering Variants don’t work quite like this. The markup for a result item should live in your rendering host, not in an item in Sitecore.
What if we use this field to just return the content of our search result item as JSON? Then we can parse the json on the client… So, I created a rendering variant for the Search Results
component called NewsListResults
. Normally, in Headless SXA, this is the only thing you need to define a variant. But if you manually insert a Scriban Template
(/sitecore/templates/Foundation/Experience Accelerator/Scriban/Scriban
) item, you can insert whatever you like:
Here, you can see that you can insert json into the variant and that gets returned in the Html
field of the result. This basic approach works for some simple content, but if you need more massaging of the data, you can create your own custom embedded function, like this:
public class MapNewsArticleSearchResult : IGenerateScribanContextProcessor
{
private delegate string MapFields(Item item);
public void Process(GenerateScribanContextPipelineArgs args)
{
args.GlobalScriptObject.Import("custom_mapnewsarticle", new MapFields(MapContent));
}
public string MapContent(Item item)
{
if (item == null)
{
return string.Empty;
}
DateField dateField = item.Fields["StartDate"];
var dateString = dateField.DateTime.ToString("MMMM dd, yyyy");
var result = new JObject()
{
["Id"] = item.ID.Guid.ToString("D"),
["Title"] = item["Title"],
["StartDate"] = dateString,
["ShortDescription"] = item["ShortDescription"],
};
return JsonConvert.SerializeObject(result);
}
}
Next, we need to call this endpoint from our rendering host. In my case, we’re using NextJS. I’ll start by creating a new class under /lib
called search-service.ts
that will handle the call to the search endpoint on the CD.
import axios, { AxiosRequestConfig } from 'axios';
export type SearchResult = {
id: string;
result: string;
}
export class SearchProvider {
private hostname: string;
private headerConfig: AxiosRequestConfig<unknown>;
constructor() {
this.hostname = process.env.SITECORE_API_HOST || '';
this.headerConfig = {
headers: {
"Access-Control-Allow-Headers": "*",
"Content-Type": "application/json",
"Accept": "application/json",
}
}
}
async results(q: string | string[] | undefined, scope: string | string[] | undefined, variant: string | string[] | undefined, order: string | string[] | undefined, offset: string | string[] | undefined, pageSize: string | string[] | undefined, sig: string | string[] | undefined, f: string | undefined): Promise<SearchResult> {
let facetQueryString = '';
if (f != undefined && f != '') {
const rawFacetString = f?.split('|');
if (rawFacetString) {
rawFacetString.forEach((facet: string) => {
facetQueryString += `&${facet.replace(':', '=')}`;
});
}
if (facetQueryString.endsWith('|') || facetQueryString.endsWith('&')) {
facetQueryString = facetQueryString.slice(0, -1);
}
}
const url = `${this.hostname}/sxa/search/results?s=${scope}&itemid={42423A8B-B19E-4259-9E6B-3F0206B7ACE4}&sig=${sig}&q=${q}&e=${offset}&p=${pageSize}&o=&v=%7B${variant}%7D&o=${order}${facetQueryString}`;
const response = await axios.get(url, this.headerConfig);
return response.data;
}
}
export const searchProvider = new SearchProvider();
Next, I’ll create an endpoint on the server side of our rendering host called /pages/api/search/results.ts
.
import { searchProvider } from "lib/search-service";
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const response = await searchProvider.results(req?.query?.q, req?.query?.scope, req?.query?.variant, req?.query?.order, req?.query?.offset, req?.query?.pageSize, req?.query?.sig, req?.query?.f as string);
res.status(200).json(response);
}
Finally, I’ll create a new React component called SearchResults.tsx
.
import { ComponentParams, ComponentRendering } from "@sitecore-jss/sitecore-jss-nextjs";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
/* eslint-disable react-hooks/rules-of-hooks */
export type SearchResultsProps = {
rendering: ComponentRendering & { params: ComponentParams };
params: { [key: string]: string };
};
export interface SxaSearchResult {
Id: string;
Url: string;
}
interface NewsSearchResultItem extends SxaSearchResult {
Html: {
ArticleImagePath: string;
AnnouncementType: string;
StartDate: string;
ArticleTitle: string;
ShortDescription: string;
ExternalLink: string;
ItemUrl: string;
}
}
export const NewsListResults = (props: SearchResultsProps): JSX.Element => {
const [results, setResults] = useState<SxaSearchResult[]>([]);
const searchParams = useSearchParams();
const calculateOffset = (pageSize: number, pageNumber: number) => {
if (pageNumber === 2) {
return pageNumber * pageSize;
} else if (pageNumber > 2) {
return (pageNumber - 1) * pageSize;
}
return 0;
};
const parseResults = (data: any) => {
if (data.Results.length > 0)
return {
...data,
Results: data.Results.map((result: any) => {
return {
...result,
Html: JSON.parse(result.Html),
};
}),
};
};
const getSearchResults = async (
searchPhrase: string | null,
searchSignature: string,
scope: string,
variant: string,
order: string,
currentPage: string | null,
pageSize: string | null,
facets: string | null
): Promise<{ [key: string]: any }> => {
const pageNumber = currentPage === null ? 1 : parseInt(currentPage);
const pageSizeNumber = pageSize === null ? 0 : parseInt(pageSize);
const offset = calculateOffset(pageSizeNumber, pageNumber);
const url = `/api/search/results?q=${searchPhrase}&scope=${scope}&variant=${variant}&order=${order}&offset=${offset}&pageSize=${pageSizeNumber === 0 ? "" : pageSize}&sig=${searchSignature}&f=${facets}`;
const results = await fetch(url);
const data = await results.json();
return parseResults(data);
};
useEffect(() => {
getSearchResults(
searchParams.get("q") ?? "",
props.params.SearchSignature,
props.params.Scope,
"4493DE2F-EE98-4E2B-9BD1-30A35917D8A0",
"news-and-event-date,Descending",
searchParams.get("p"),
props.params.PageSize,
searchParams.get("f") ?? ""
).then((data) => {
setResults(data?.Results);
});
}, [searchParams]);
return (
<div className={`component search-component ${props.params.styles.trimEnd()} `} id={props.params.RenderingIdentifier}>
<div className="row">
<div className="col-12 col-md-2">
{/* Facets would presumably go here */}
</div>
<div className="col-12 col-md-10">
<div className="row">
<div className="component col-12 search-results tile-template ">
<ul className="search-result-list">
{results?.map((item: NewsSearchResultItem) => {
return (
<li key={item.Html.ArticleTitle}>
<div className="spotlight-search-result news-article">
<div className="search-result-content">
<div className="date">{item.Html.StartDate}</div>
<h4 className="title">{item.Html.ArticleTitle}</h4>
<div className="description">{item.Html.ShortDescription}</div>
<div className="button link-button">
<a href={item.Html.ItemUrl}>Read More</a>
</div>
</div>
</div>
</li>
);
})}
</ul>
</div>
</div>
</div>
</div>
</div>
);
};
Now, once you’ve created some content based on the template included in the Search Scope created earlier, published any appropriate code, and rebuilt indexes, if you hit your page with the Search Results
component, you should see results!
Conclusion
I will be honest… I am not the best hipster front-end developer, so you will find that there is PLENTY of room for optimization in the code in this post. Feel free to refine away! But hopefully the basics of this post will help someone else that is migrating from a Classic SXA site using many of the search features, to Headless SXA. Also, I didn’t go into any detail about how to deal with other search bits like Search Box
, Result Count
, Page Selector
, etc… but the approach is the same. I may do more detailed posts about them in the future, but for now, I hope someone finds this helpful!
Happy Sitecore trails, my friends!