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!