Headless SXA: Using the Classic SXA Search feature

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 ListFacetcalled news-and-event-dateunder 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 NameSearchResults
Parameters TemplateTemplates/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!

Leave a comment