How to Build Content Search in Sitecore JSS Using GraphQL and SXA Tags

H
yaochang

Yaochang Liu

Sitecore Technology MVP 2025 | Sitecore Full Specialization Certified Developer (XM Cloud | XP | CDP | Personalize | Content Hub | Order Cloud)

Sitecore JSS, combined with GraphQL and SXA Tags, offers a powerful solution for implementing robust content search functionality in a headless architecture.

This article will guide you through the process of building a content search feature in Sitecore JSS, leveraging GraphQL to fetch structured content efficiently and SXA Tags to categorize and filter data.

Step 1: Create categorized tags

Create categorized tags in /sitecore/content/{tenant}/{site}/Data/Tags

Step 2: Create the GraphQL Query

2.1 Create the GraphQL Query for tags

Create the Query as a constant, and define an interface for the data returned from the GraphQL query.

export const ContentTagsQuery = `
query ContentTagsQuery($language: String!) {
  contentSearchTags: item(path:"{CA894C0E-946C-412E-ADE9-3B84FBB9A0E5}",language:$language){
    children(includeTemplateIDs:"{25A6B824-672F-4C15-9B98-0B231C80F81C}"){
      taxonomies: results{
        displayName
        name
        children(includeTemplateIDs:"{6B40E84C-8785-49FC-8A10-6BCA862FF7EA}"){
          tags: results{
            name
            id
          }
        }
      }
    }
  }
}
`;
interface ContentTag {
  name: string;
  id: string;
}

export interface ContentTaxonomy {
  displayName: string;
  name: string;
  children: {
    tags: ContentTag[];
  };
}

export interface GraphQLContentTagsResponse {
  contentSearchTags: {
    children: {
      taxonomies: ContentTaxonomy[];
    };
  };
}

2.2 Create the GraphQL Query for contents

import { TextField, ImageField } from '@sitecore-jss/sitecore-jss-nextjs';
import { PageInfo } from '@sitecore-jss/sitecore-jss/graphql';
import { GraphQLSxaTag } from '../types/sxaTag';

export const generateContentSearchQuery = (taxonomies: string[][] = []) => {
  taxonomies = taxonomies.filter((tags) => tags.length);
  return `
    query ContentSearchQuery($keyword: String!, $language: String!, $after: String = null, $first: Int = null) {
      search(
        where: {
          AND: [
            {
              name: "_path"
              value: "{C36E43FB-882B-4848-869F-5045CB508173}"
              operator: CONTAINS
            }
            { name: "_language"  value: $language }
            { name: "_hasLayout" value: "true" }
            {
              name: "_templates"
              value: "{F9419E6E-D1A9-4CA0-9D88-A1E5CC7B5731}"
              operator: CONTAINS
            }
            {
              OR:[
                {
                  name: "title"
                  value: $keyword
                  operator: CONTAINS
                }
                {
                  name: "content"
                  value: $keyword
                  operator: CONTAINS
                }
              ]
            }
            ${
              !taxonomies.length
                ? ''
                : `
              {
                AND:[
                  ${taxonomies.map(
                    (tags) => `
                  {
                    OR:[
                      ${tags
                        .map(
                          (tag) => `
                      {
                        name: "sxaTags"
                        value: "${tag}"
                        operator: CONTAINS
                      }
                      `
                        )
                        .join()}
                    ]
                  }
                  `
                  )}
                ]
              }
              `
            }
          ]
        }
        after: $after
        first: $first
      ){
        pageInfo{
          endCursor
          hasNext
        }
        results{
          ...on PostPage{
            url{
              path
            }
            sxaTags{
              ...on MultilistField{
                targetItems{
                  ...on Tag{
                    name
                    parent{
                      name
                    }
                  }
                }
              }
            }
            title{
              value
            }
          }
        }
        total
      }
    }
`;
};

export interface GraphQLContentSearchResult {
  url: {
    path: string;
  };
  sxaTags: {
    targetItems: GraphQLSxaTag[];
  };
  title: TextField;
}

export type GraphQLContentSearchResponse = {
  search: {
    pageInfo: PageInfo;
    results: GraphQLContentSearchResult[];
    total: number;
  };
}; 

Step 3: Create the Component

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Text, Image as JssImage } from '@sitecore-jss/sitecore-jss-nextjs';
import NextLink from 'next/link';
import {
  generateContentSearchQuery,
  GraphQLContentSearchResult,
  GraphQLContentSearchResponse,
} from '../../graphql/graphql-content-search';
import {
  ContentTagsQuery,
  ContentTaxonomy,
  GraphQLContentTagsResponse,
} from '../../graphql/graphql-content-tags';
import { NextRouter, useRouter } from 'next/router';
import graphqlClientFactory from 'lib/graphql-client-factory';
import { useI18n } from 'next-localization';

type ContentListProps = {
  contents: GraphQLContentSearchResult[];
  ctaText: string;
};

const List = (props: ContentListProps): JSX.Element => (
  <div>
    {props.contents.map((item, index) => (
      <NextLink key={index} href={item.url.path}>
        <div>{item.sxaTags.targetItems.map((tag) => tag.name).join(',')}</div>
        <div>
          <Text field={item.title} />
        </div>
        <div>{props.ctaText}</div>
      </NextLink>
    ))}
  </div>
);

export const SEARCH_PAGE = '/search';
export const KEYWORD_PARAM = 'q';

const getQueryList = (router: NextRouter, queryKey: string): string[] =>
  ((router?.query[queryKey] as string) ?? '').split(',').filter((tag) => tag);

const ContentSearch = () => {
  const router = useRouter();
  const { t } = useI18n();
  const [contentTaxonomies, setContentTaxonomies] = useState<ContentTaxonomy[]>([]);
  const [contentItems, setContentItems] = useState<GraphQLContentSearchResult[]>([]);
  const q = useMemo(() => (router?.query[KEYWORD_PARAM] as string) ?? '', [router?.query]);
  const queryTaxonomies = useMemo(
    () =>
      contentTaxonomies.map((item) => ({
        taxonomy: item.name,
        tags: getQueryList(router, item.name).map((x) => ({
          name: x,
          id: item.children.tags.find((c) => c.name == x)?.id ?? '',
        })),
      })),
    [router?.query]
  );
  const gqlTaxonomies = useMemo(
    () => queryTaxonomies.map((x) => x.tags.map((tag) => tag.id)),
    [router?.query]
  );
  const language = useMemo(() => router.locale ?? 'en', [router?.locale]);
  const [after, setAfter] = useState('');
  const [hasMore, setHasMore] = useState(false);
  const defaultItemsPerQuery = 12;

  const getContentTaxonomies = useCallback(async () => {
    const graphQLClient = graphqlClientFactory();
    const result = await graphQLClient
      .request<GraphQLContentTagsResponse>(ContentTagsQuery, {
        language,
      })
      .then((res) => res);
    if (result.contentSearchTags?.children?.taxonomies) {
      setContentTaxonomies(result.contentSearchTags.children.taxonomies);
    }
  }, [language]);

  const getContents = useCallback(async () => {
    const graphQLClient = graphqlClientFactory();
    const result = await graphQLClient
      .request<GraphQLContentSearchResponse>(generateContentSearchQuery(gqlTaxonomies), {
        keyword: q,
        language,
        after,
        first: defaultItemsPerQuery,
      })
      .then((res) => res);
    setAfter(result.search.pageInfo.endCursor);
    setHasMore(result.search.pageInfo.hasNext);
    return result.search.results.filter((item) => item.url);
  }, []);

  const getSearchResult = useCallback(async () => {
    const contents = await getContents();
    setContentItems(contents);
  }, [getContents]);

  const loadMore = useCallback(async () => {
    const contents = await getContents();
    contentItems.push(...contents);
    setContentItems(contentItems);
  }, [getContents]);

  const handleTagChange = useCallback(
    (taxonomy: string, checked: boolean, tagName: string) => {
      let query = `${KEYWORD_PARAM}=${q}`;

      const operateItem = queryTaxonomies.find((x) => x.taxonomy == taxonomy);
      const restItems = queryTaxonomies.filter((x) => x.taxonomy !== taxonomy && x.tags.length);
      if (operateItem) {
        let tags = operateItem.tags.map((x) => x.name);
        if (checked) tags.push(tagName);
        else tags = tags.filter((tag) => tag !== tagName);
        tags = tags.filter((tag) => tag);
        if (tags.length) query += `&${operateItem.taxonomy}=${tags.join(',')}`;
      }

      restItems.forEach((x) => {
        query += `&${x.taxonomy}=${x.tags.map((x) => x.name).join(',')}`;
      });

      router?.push(`${SEARCH_PAGE}?${query}`);
    },
    [router]
  );

  useEffect(() => {
    if (!!contentTaxonomies) {
      getContentTaxonomies();
    }
  }, [contentTaxonomies]);

  useEffect(() => {
    getSearchResult();
  }, [getSearchResult]);

  return (
    <div className="component">
      <div className="component-content">
        {contentTaxonomies.length &&
          contentTaxonomies.map(
            (tags, index) =>
              tags.children.tags.length && (
                <>
                  <div key={index}>{tags.displayName}</div>
                  <div>
                    {tags.children.tags.map((tag, tagIndex) => {
                      const queryTaxonomy = queryTaxonomies.find((x) => x.taxonomy == tags.name);
                      const checked =
                        queryTaxonomy && queryTaxonomy.tags.find((x) => x.id == tag.id);
                      return (
                        <>
                          <label key={tagIndex}>
                            {checked ? (
                              <input
                                type="checkbox"
                                checked
                                value={tag.id}
                                onClick={(e) =>
                                  handleTagChange(tags.name, e.currentTarget.checked, tag.name)
                                }
                              />
                            ) : (
                              <input
                                type="checkbox"
                                value={tag.id}
                                onClick={(e) =>
                                  handleTagChange(tags.name, e.currentTarget.checked, tag.name)
                                }
                              />
                            )}

                            <span>{tag.name}</span>
                          </label>
                        </>
                      );
                    })}
                  </div>
                </>
              )
          )}

        <div>
          {queryTaxonomies
            .filter((item) => item.tags.length)
            .map((item, index) =>
              item.tags.map((tag, tagIndex) => (
                <>
                  <span
                    key={index + tagIndex}
                    onClick={() => handleTagChange(item.taxonomy, false, tag.name)}
                  >
                    {tag.name}
                  </span>
                  <br />
                </>
              ))
            )}
        </div>
        <hr />
        {!contentItems || !contentItems.length ? (
          <p>No result.</p>
        ) : (
          <>
            <List contents={contentItems} ctaText={t('Read More') || 'read more'} />
            {hasMore && <button onClick={loadMore}>{t('Load More') || 'load more'}</button>}
          </>
        )}
      </div>
    </div>
  );
};

export default ContentSearch; 

Step 4: Integrate the Component into a page

import React, { ReactElement } from 'react';
import Head from 'next/head';

import PageLayout from '../../components/NonSitecore/PageLayout';
import ContentSearch from '../../components/NonSitecore/ContentSearch';

const Search = () => <ContentSearch />;

Search.getLayout = function getLayout(page: ReactElement) {
  return (
    <>
      <Head>
        <title>Page - Search</title>
      </Head>

      <PageLayout>{page}</PageLayout>
    </>
  );
};

export default Search; 

Happy coding!