Sitecore Search + Sitecore Forms + NextJS – What a beautiful combination

Hey, Sitecore folks! The new year is almost here. Wishing you all the best, and I have a feeling that 2024 is going to be BIG for you guys! 🙂

Today’s post is all about the awesome Sitecore Search and how to get it working smoothly with Sitecore Forms and NextJS.

The scenario is this:
There is a product detail page in Sitecore(wildcard page), which contains a “product-detail” component and a Sitecore Form. The product data is fetched from Sitecore Search, and the Sitecore Form includes a dropdown that displays the available reseller for the specific product.

The focus in this post will be how to create a custom dropdown in Sitecore Forms and populate it with “Sitecore Search data” in Next.js.

First out is to create the dropdown presenting the reseller in Sitecore Forms. In order for NextJs to populate the “correct” reseller, we have to pass down how to retrieve the product ID, which is located in the URL. Something like this:
http://sandboxsite/detail/8205843/a-very-cool-product-name

So, we need to throw in a new field in our custom dropdown control called “Id extractor” to show where the product ID can be found in the URL, specifically in the 3rd segment.

*The custom dropdown is basically a clone of the Sitecore Forms – Dropdown List. I got rid of all the extra stuff, like the dropdown data source and whatnot. We’ll handle that on the client side instead 🙂

The next step is to set up the call to Sitecore Search from NextJS.
Let me quickly explain the data in Sitecore Search. So, we’ve got a product with its attributes, and the content type is set to “product”. Then, we’ve got a similar setup for the reseller, except for the content type, which is set to reseller. Both products and resellers have a combined ID, including the source (Sitecore search source) and the actual ID (product/reseller). This is because the document ID in Sitecore Search needs to be unique. Also, we’re looking at having many sources in the future – think of a source as a “catalog” in Sitecore Commerce” or an “index collection” in Solr Cloud.

The cool thing about NextJs is that it lets you mix server-side and client-side stuff. We’re gonna make a server-side function that calls Sitecore Search. And to create the JSON for the query, we’ll use Sitecore Search. With Sitecore Search, you can try out queries, something like this:

And best of all, there is this option => Copy as cURL using Sitecore host. Here is what it gives us:

curl 'https://discover-euc1.sitecorecloud.io/discover/v2/XXXXXX' -H 'accept-encoding: gzip, deflate, br' -H 'content-type: application/json' -H 'authorization: XX-XXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXX' -H 'accept: application/json' --data-binary $'{"widget":{"items":[{"entity":"content","rfk_id":"xxxx","search":{"content":{},"filter":{"type":"and","filters":[{"type":"eq","name":"id","value":"xxxx_xxxx"},{"type":"eq","name":"type","value":"product"}]}}}]},"context":{"locale":{"country":"se","language":"sv"}}}' --compressed

Now we just have to make it NextJS friendly, we will create an “api file”, so let’s locate api in pages and create a ts file => sitecoresearch.ts. Here is the code for querying/calling sitecore search:

import type { NextApiRequest, NextApiResponse } from 'next';
import { FilterCriteria } from '.path-to/typings/sitecoreSearch'; 

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).end('Method Not Allowed');
  }

  const { rfkId, filters, country, language } = req.body;

  if (!validateFilterCriteria(filters)) {
    return res.status(400).json({ error: 'Invalid filter criteria' });
  }

  const apiUrl = process.env.SITECORE_SEARCH_API_URL;
  const authorizationToken = process.env.AUTHORIZATION_TOKEN;

  try {
    const response = await fetch(apiUrl, {
      method: 'POST',
      headers: {
        'Accept-Encoding': 'gzip, deflate, br',
        'Content-Type': 'application/json',
        Authorization: authorizationToken,
        Accept: 'application/json',
      },
      body: JSON.stringify({
        widget: {
          items: [
            {
              entity: 'content',
              rfk_id: rfkId,
              search: {
                content: {},
                filter: filters,
              },
            },
          ],
        },
        context: {
          locale: {
            country: country,
            language: language,
          },
        },
      }),
    });

    if (!response.ok) {
      throw new Error('Failed to fetch data from external API');
    }

    const data = await response.json();
    res.status(200).json(data);
  } catch (error) {
    console.error('Fetch error:', error);
    res.status(500).json({ error: 'Internal Server Error' });
  }
}

function validateFilterCriteria(filters: any): filters is FilterCriteria {
  // Implement actual validation logic here
  return typeof filters === 'object' && filters.type && Array.isArray(filters.filters);
}

*Here is the interface for FilterCriteria:

 export interface FilterCriteria {
    type: string;
    filters: Array<{
      type: string;
      name: string;
      value: string;
    }>;
  }

Wonderful! So now, on the client-side, we can totally do something like this:

const response = await fetch('/api/sitecoresearch', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ rfkId, filters, country, language })
        });

It’s time to create the custom Sitecore Forms dropdown in NextJS for the Reseller selection.
Remember that wildcard page we have for showcasing a product? Well, to fill the dropdown with resellers, we’ll need to grab the product ID from the URL and then use it to call Sitecore Search and fetch the corresponding reseller ID. After that, we’ll need to make a second call to retrieve the reseller’s name and email. Exciting stuff!

Here is the complete code for the custom dropdown:

defaultFieldFactory.setComponent(
  ResellerDropDownType as FieldTypes,
  (props: ListFieldProps<DropdownListViewModel>) => {
    const { field, onChange } = props;
    const [productId, setProductId] = useState(null);
    const [productDetailData, setProductDetailData] = useState(null);
    const [resellerData, setResellerData] = useState([]);
    const rfkId = process.env.RFK_ID;
    

    // Generic function to create filters
    function createFilters(type: string, source: string, id: string) {
      return {
        type: 'and',
        filters: [
          { type: 'eq', name: 'id', value: `${source}_${id}` },
          { type: 'eq', name: 'type', value: `${type}` }
        ]
      };
    }

    // Function to call Sitecore Search API
    async function callSitecoreSearchFetchApi(rfkId: string, filters:FilterCriteria, country: string, language: string) {
      try {
        const response = await fetch('/api/sitecoresearch', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ rfkId, filters, country, language })
        });

        if (!response.ok) throw new Error('Network response was not ok');
        const data = await response.json();
        return data.widgets && data.widgets.length > 0 ? data.widgets[0].content : [];
      } catch (error) {
        console.error('Error while calling API:', error);
        return [];
      }
    }


    function handleOnChange(
      field: ValueFormField,
      newValue: string,
      callback: FieldChangeCallback
    ) {
      let valid = true;
      const errorMessages = [];
      
      // custom client validation logic here
      if (field?.model?.required && !newValue) {
        valid = false;
        errorMessages.push(`${field.model.title} is required`);
      }

      callback(field?.valueField?.name, [newValue], valid, errorMessages);
    }

    // Extract product ID from URL
    useEffect(() => {
      const idSegment = parseInt(field.model.idExtractor, 10);
      const pathParts = window.location.pathname.split('/');
      const id = pathParts[idSegment - 1];
      setProductId(id);
    }, []);

    // Fetch product detail data
    useEffect(() => {
      async function fetchProductDetail() {
        if (productId) {
          const data = await callSitecoreSearchFetchApi(rfkId, createFilters('product', 'sandboxsource', productId), country, language);
          if (data.length > 0) setProductDetailData(data[0]);
        }
      }

      void fetchProductDetail();
    }, [productId, rfkId, country, language]);

    // Fetch reseller data based on product details
    useEffect(() => {
      async function fetchResellerData() {
        if (productDetailData?.product_reseller_id) {
          const data = await callSitecoreSearchFetchApi(rfkId, createFilters('reseller', 'sandboxsource', productDetailData.product_reseller_id), country, language);
          setResellerData(data);
          if (data.length === 1) handleOnChange(field, data[0]?.reseller_company_email, onChange);
        }
      }

      void fetchResellerData();
    }, [productDetailData, rfkId, country, language, field, onChange]);

    return (
      <>
        <Label {...props}></Label>
        <select
          className={field?.model?.cssClass}
          name={field?.valueField?.name}
          id={field?.valueField?.id}
          defaultValue={resellerData.length === 1 ? resellerData[0]?.reseller_company_email : ""}
          onChange={(e) => handleOnChange(field, e.target.value, onChange)}
        >
          {field.model.showEmptyItem && <option key="0"></option>}
          {resellerData.map((item, index) => (
            <option key={index} value={item.reseller_company_email}>{item.name}</option>
          ))}
        </select>
        <FieldErrorComponent {...props}></FieldErrorComponent>
      </>
    );
  }
);

I’ve had some real hair-pulling moments and imposter syndrome episodes when making this 😅
One of them was to make the dropdown to post a value when there is only one item (option).
If you look at this part (in row 87). Notice that I have to force the onchange call.

if (data.length === 1) handleOnChange(field, data[0]?.reseller_company_email, onChange);

I also need to set the defaultValue on the select:

defaultValue={resellerData.length === 1 ? resellerData[0]?.reseller_company_email : ""}

There are more to cover, but let’s stay here for now.

I’m totally loving Sitecore Search! I can see so much potential in Sitecore Search.
Goodbye SolrCloud, hello Sitecore Search? 😉

That’s all for now folks 🙂


Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.