Implementing Range Facets in Sitecore Search with Next.js

Range facets are powerful tools for filtering numerical data like prices, dates, or quantities in your search interface. This guide walks through implementing a range facet in Sitecore Search and integrating it with a Next.js application.

Part 1: Configuring Range Facets in Sitecore Search

In first step , we will create a “range” facet in Sitecore Search, In my dataset I have party_price attribute field, which is already configured within Sitecore Search. These attributes are readily accessible through the Domain Settings,

which you can find from Administration → Domain Settings → Attributes

Then you need to select a product entity or All from Entity dropdown

Click on party_price field

you will find some configuration inside it

To use this field as facet, navigate to the Use For Features and check the Facet checkbox

Save the changes and publish it. (Most forgettable 😄)

Now, our next step is to configuring our Facet , so for that we will again navigate to Domain settings and then click on the Feature Configuration

You can see list features like, API Response, Facets, Filters, Personalization etc. As we want to configure the Facet will navigate to the Facet feature.

From the facet list you can find the newly created facet in my case It will be party_price

click on the party_price facet for configuration.

  1. Ensure that:
    • AGGREGATION TYPE is set to Histogram.
    • OPERATOR is set to Dynamic OR.
  2. Save and publish the changes.

I’m skipping the Sitecore Configuration part as this is all about the “Range” facet. (I’ll cover in Facet configuration part).

This is all from Siirecore Search facet configuration. Now we’ll jump into Next js configuration part.

Part 2: Implementing Range Facets in Next.js

Configure Search Results Widget:

To setup the range filter first of all you need to pass the config while calling the useSearchResults widget.

const searchResults = useSearchResults<ProductSearchResultModel, InitialState>({
    config: {
      facets: {
        party_price: { type: 'range' },
      },
    },

in above code snippet you can see I have configured the party_price as type of range. If you are not configure the specific type it would behave differently !

Create the Range Facet Component:

Now we will create the Price Facet Component where we will implement our facet things.

const PriceFacet = ({ facet }: FacetProps) => {
  const min = Math.floor(facet?.value?.[0].min);
  const max = Math.floor(facet?.value?.[facet?.value.length - 1].max);
  return (
    <SearchResultsFacetValueRange
      max={max}
      min={min}
      autoAdjustValues={false}
      className={clsx(styles['sitecore-range-facet-root'], '!mt-4')}
    >
      <RangeFacet.Track
        className={clsx(
          styles['sitecore-range-facet-track'],
          '!block !bg-color-brand-primary-1-base !h-2'
        )}
      >
        <RangeFacet.Range className={clsx(styles['sitecore-range-facet-range'])} />
      </RangeFacet.Track>
      <RangeFacet.Start
        className={clsx(
          styles['sitecore-range-facet-start'],
          'hover:!bg-white hover:!border-[1px] hover:!border-color-brand-primary-1-base '
        )}
      >
        {(value) => <span className="!top-[-20px]">${value}</span>}
      </RangeFacet.Start>
      <RangeFacet.End
        className={clsx(
          styles['sitecore-range-facet-end'],
          'hover:!bg-white hover:!border-[1px] hover:!border-color-brand-primary-1-base '
        )}
      >
        {(value) => <span className="!top-[-20px]">${value}</span>}
      </RangeFacet.End>
    </SearchResultsFacetValueRange>
  );
};

You can also override the style based on you desiIn requirement in is codebase i’m using some tailwind 🧑🏻‍🎨 classes for customisation.

Render the Range Facet Conditionally:

As this is range facet, for rendering it we have to do one additional check while rendering the regular facet.

<AccordionFacets.Facet
      facetId={facet.name}
      key={facet.name}
      className={facetRoot({ className: 'border-b-[0px]' })}
    >
      <FacetHeader facet={facet} actions={actions} settings={settings} />
      <AccordionFacets.Content>
        {facet.name === 'party_price' ? (
          <PriceFacet facet={facet} actions={actions} settings={settings} />
        ) : (
          <StandardFacet facet={facet} actions={actions} settings={settings} />
        )}
      </AccordionFacets.Content>
    </AccordionFacets.Facet>

As of now i’m compering with the static value, but there is also an other way to do this.

Build a Custom Range Label:

Here some more things to keep in your note that we also have to create separate label for the range it will not work like other facet.

for that you can export the function and can reuse it.

export const buildRangeLabel = (min: number | undefined, max: number | undefined): string => {
// Constructs the label string for the range (e.g., "$10 - $100", "< $10", "> $100").
  return typeof min === 'undefined'
    ? `< $${max}`
    : typeof max === 'undefined'
    ? ` > $${min}`
    : `$${min} - $${max}`;
};

export const buildFacetLabel = (selectedFacet: any, overridenLabel: string) => {
  if ('min' in selectedFacet || 'max' in selectedFacet) {
    return `${buildRangeLabel(selectedFacet.min, selectedFacet.max)}`;
  }
  return `${overridenLabel}`;
};

here i’m also passing the overridenLabel if you want to override the existing label from Sirecore CMS side. otherwise you can remove it.

Implement a Custom Facet Header:

as I’m using separate FacetHeader component i’ll be going use buildFacetLabel for dynamic label. (which are shown as selected facet at top of the facet section)

//Implement a Custom Facet Header
function FacetHeader({ facet, actions, settings }: FacetProps) {
  const selectedFacets = useSearchResultsSelectedFacets();
  const selectedFacetIds = selectedFacets.map((x) => x.id);
  const isFacetSelected = selectedFacetIds.includes(facet.name);
  const dictionary = useDictionary();
  return (
    <AccordionFacets.Header className={'flex flex-col justify-between'}>
      <AccordionFacets.Trigger className={styles['sitecore-accordion-trigger'] + ' group'}>
        <span className={facetLabel()}>
   -->     {buildFacetLabel(selectedFacets, settings?.facetLabelOverride || facet.label)}
        </span>
        {isFacetSelected ? (
          <span
            className={clearLabel()}
            // This is a span instead of a button because we are already inside a button
            onClick={(e) => {
              e.stopPropagation();
              return actions.onRemoveFilter({ facetId: facet.name, type: 'range' });
            }}
          >
            {dictionary.getDictionaryValue('ClearFacetPLP', 'Clear')}
          </span>
        ) : null}
        <IconHelper className={facetIcon()} icon={'chevron-down'} />
      </AccordionFacets.Trigger>
    </AccordionFacets.Header>
  );
}

Final result 🥳

You can also deep dive some additional configuration ⚙️

Default facet selection

To set the selected filter on the url (so, you can use as default selected filter on page load), we can do something like:

function facetToUrl(selectedFacets: FacetValue[]) {
// Converts selected facets to a URL hash string.  Handles range facets specifically.
  if (!selectedFacets.length) {
    return '';
  }
  const facets: Record<string, string> = {};
  selectedFacets.forEach((facet) => {
    let value = facet.valueLabel ?? facet.facetValueText;
    if (facet?.type === 'range') {
      value = `${facet?.min}-${facet.max}`;
    }
    // Depending on when it's called, sometimes it comes as facet.valueLabel, other times it's facet.facetValueText
    if (!value) {
      return;
    }
    const key = FACET_PREFIX + facet.facetId;
    if (!facets[key]) {
      facets[key] = '';
    } else {
      facets[key] += '|';
    }
    facets[key] += value;
  });
  const params = new URLSearchParams(facets);

  return params.toString();
}

//Hook to keep facets synchronized with the URL.
export const useEnsureFacetUrl = (
  actions: SearchResultsWidget['ActionProps'],
  facets: SearchResponseFacet[]
) => {
  const router = useRouter();
  const selectedFacetsFromApi = useSearchResultsSelectedFilters();

  const selectedFacetsFromApiUrl = decodeURI(facetToUrl(selectedFacetsFromApi));

  const [prevFacetUrl, setPrevFacetUrl] = useState<string>();

  useEffect(() => {
    if (prevFacetUrl === undefined) {
      setPrevFacetUrl(router.asPath.split('#')[1] ?? '');
    } else {
      if (prevFacetUrl !== selectedFacetsFromApiUrl) {
        setPrevFacetUrl(selectedFacetsFromApiUrl);
        router.push(
          {
            pathname: window.location.pathname,
            // window.location.search includes the '?' if there is a querystring.
            // This caused extra '?' to be added each time.
            // If there is no querystring, there is no '?' so this issue wasn't caught earlier.
            query: window.location.search.replace(/^\\?/, ''),
            hash: selectedFacetsFromApiUrl,
          },
          undefined,
          { scroll: false }
        );
      }
    }
  }, [actions, facets, prevFacetUrl, router, selectedFacetsFromApiUrl]);
};

final step for set up the selected facet in url is using this hook inside your search component

// In your search component:
// To ensure that facets are synced with the URL:
  useEnsureFacetUrl(actions, facets);

to use as default selection you need one more configuration to convert url to facet value

export function urlToFacet(hash: string) {
  const query = new URLSearchParams('?' + hash);

  const facets: FacetValue[] = [];

  for (const [key, value] of query.entries()) {
    if (!key.startsWith(FACET_PREFIX)) {
      continue;
    }
    const facetId = key.split(FACET_PREFIX)[1];
    const valueArray = value.split('|');
    valueArray.forEach((x) => {
      if (facetId == filterByPrice.partyPrice) {
        const minMax = x?.split('-');
        facets.push({ facetId, min: Number(minMax[0]), max: Number(minMax[1]) });
      } else {
        facets.push({ facetId, facetValueText: x });
      }
    });
  }
  return facets;
}

use use it you can call it from useSearchResults in state config as selectedFacets

 state: () => {
      const hasWindow = typeof window !== 'undefined';
      const hash = hasWindow ? window.location.hash.replace(/^#+/, '') : '';
      const facetsFromUrl = urlToFacet(hash); // Get selected facets from the URL.
      return {
        selectedFacets: facetsFromUrl, //Set these as the initial selected facets.
      };
    },
    

Conclusion

With this implementation, you’ll have a fully functional range facet in your Sitecore Search-powered Next.js application. The range facet supports:

  • Dynamic min/max values
  • Visual range slider
  • URL synchronization
  • Clear formatting of selected ranges
  • Proper integration with existing facet infrastructure

Happy coding! 🚀

Leave a comment