Advanced (deep) global search with Sanity, Next JS & React

Peter Rolfsen
5 min readNov 3, 2023

--

This tutorial will teach you to do a deep search of all your structured documents in Sanity. Here we will create a search that can access all the content of a document and return all matches.

Technology: Sanity, GROQ, Next JS, React, TypeScript

This tutorial also assumes that you have already set up a Next Sanity project. I have used the default blog-template for this project.

If not. Check out the tutorial on simple search.

Note: This tutorial is an extension of the simple search tutorial.
If you are setting up the search from scratch. Please feel free to check it out.

What we will learn:

  • Searching through all content of content types, even blockContent
  • Gathering all queries variables in one query.

1. Deep search of all blockContent

Accessing blockContent:

In order to use GROQ to search in blockContent, we have to find the text that is stored in the blockContent array.

This is actually quite simple, but can vary depending on if you have customised you blockContent or added your own components to it.

Figure 2: BlockContent
Figure 3: Structure

In figure 2 I have a basic blockContent field.
As we can see in the inspect window of Sanity. It is structured such as in figure 3.

Body is an array and can have multiple children.
Children is also an array.

Therefore, the groq will be formated as

body[].children[].text

If I had given the blockContent another name, like authors Bio field.

It would be

bio[].children[].text

Use the inspect window to navigate your custom blockContent fields.

This would be the same for any field.

Next up, I will store them in variables and use the variables to search for matches on the queryString.

const bodyQuery = "body[].children[].text";

const searchQuery = groq`
*[_type in ['post', 'author', 'category'] &&
(
// Searching through all of the block contents text fields for a match
${bodyQuery} match $queryString + '*'
) && !(_id in path('drafts.**')) ] | order(publishedAt desc){
body,
}
`;

This would only search through the blockContent that is named body, and return the body.

Beneath I have a completed search which allows you to search for the ‘title’-field and blockContent or text field of all my content across the whole dataset.



// api/search.ts


import client from "../../client";
import { groq } from "next-sanity";

export default async function search(req, res) {
const { query } = req.query;
// store the query to access all the text in the block content fields
const bodyQuery = "body[].children[].text";
const bioQuery = "bio[].children[].text";

const searchQuery = groq`
*[_type in ['post', 'author', 'category'] &&
(
// Searching through all of the block contents text fields for a match
${bodyQuery} match $queryString + '*' ||
${bioQuery} match $queryString + '*' ||
description match $queryString + '*' ||
title match $queryString + '*' ||
name match $queryString + '*' ||
description match $queryString + '*'
) && !(_id in path('drafts.**')) ] | order(publishedAt desc){
title,
name,
bio,
body,
text,
'slug' : slug.current,
description,
'type': _type,
}
`;

const data = await client.fetch(searchQuery, {
queryString: query,
});

res.status(200).json(data);
}

2. Joining a lot of variables

In most projects you are likely to have several content types and some of them can have several blockContent fields.

In that case I would gather all the GROQ variables in its own file like and add them all to an array.

The following code is from a bigger project, where I have several content types with several rich text fields:

// All my blockContent text files stored as variables.
// searchQueries.ts

export const titleQuery = `title`;
export const excerptQuery = `excerpt`;

export const bodyQuery = `body[].children[].text`;

// grants
export const eligibleApplicants = `eligibleApplicants[].children[].text`;
export const aboutGrantAmount = `grantProcess.aboutGrant.amount[].children[].text`;
export const aboutGrantIntro = `grantProcess.aboutGrant.intro[].children[].text`;
export const aboutGrantPurpose = `grantProcess.aboutGrant.purpose[].children[].text`;
export const aboutGrantTerms = `grantProcess.aboutGrant.terms[].children[].text`;
export const applicationProcessGuides = `grantProcess.applicationProcess.guides[].children[].text`;
export const applicationProcessRequirements = `grantProcess.applicationProcess.requirements[].children[].text`;
export const apllicationTreatmentEvaluation = `grantProcess.applicationTreatment.evaluation[].children[].text`;
export const reporting = `grantProcess.reporting[].children[].text`;

// events
export const aboutEvent = `about[].children[].text`;
export const eventProgram = `programDays[].events[].description[].children[].text`;

// articles
export const kikBody = `kikBody[].children[].text`;

// programs
export const purpose = `purpose[].children[].text`;
export const whatIsTitle = `whatIs[].children[].title`;
export const whatIsBody = `whatIs[].children[].text`;
export const whoIs = `whoIs[].children[].text`;

// reports
export const publisher = `publisher`;
export const summary = `summary[].children[].text`;

// documents
export const reportBodyTitle = `reportBody[].title`;
export const reportBodyText = `reportBody[].body[].children[].text`;
export const reportBodySubChapterTitle = `reportBody[].subChapters[].title`;
export const reportBodySubChapterText = `reportBody[].subChapters[].body[].children[].text`;

export const searchQueries = [
// grants
titleQuery,
excerptQuery,
bodyQuery,
eligibleApplicants,
aboutGrantAmount,
aboutGrantIntro,
aboutGrantPurpose,
aboutGrantTerms,
applicationProcessGuides,
applicationProcessRequirements,
apllicationTreatmentEvaluation,
reporting,
// events
aboutEvent,
eventProgram,
// articles
kikBody,
// programs
purpose,
whatIsTitle,
whatIsBody,
whoIs,
// reports
publisher,
summary,
// documents
reportBodyTitle,
reportBodyText,
reportBodySubChapterTitle,
reportBodySubChapterText,
];

I import these into my search file, where I will loop through them and join them. The reason that I do this. Is to keep the code clean and readable.

Import { searchQueries } from ./searchQueries.ts

const typeConditions = searchQueries.map((type) => {
return `${type} match $queryString + "*"`;
});

const combinedTypeConditions = typeConditions.join(' || ');

Then I insert combineTypeConditions into to GROQ to check if the queryString matches any of the variables from searchQueries.

import { groq } from 'next-sanity';
import { searchQueries } from './SearchQueries';

export default async function search(req, res) {
const { query, locale, resultlength } = req.query;

// Create an array to store the GROQ conditions for each document type
const typeConditions = searchQueries.map((type) => {
return `${type} match $queryString + "*"`;
});

const combinedTypeConditions = typeConditions.join(' || ');

const searchQuery = groq`
*[ _type in [
"newsArticle",
"grant",
"event",
"resource",
"research",
"program",
"project",
"report",
"informationArticle",
"editorTopic"
] &&
(${combinedTypeConditions}
]
title,
...
)
}`;

const data = await getClient().fetch(searchQuery, {
queryString: query,
});

res.status(200).json(data);
}

Now your frontend will return elements if either of the variables has a match to your search.

This is my frontend where I return the query. For more information on what is going on here. Check out this article.

import SearchInput from "@/components/SearchInput";
import getCardByType from "@/utils/getCardbyType";
import router, { useRouter } from "next/router";
import { useEffect, useState } from "react";

export const SearchPage = () => {
const {
query: { query: queryFromUrl },
} = useRouter();

// a state to store the search string
const [searchString, setSearchString] = useState(
typeof queryFromUrl === "string" ? queryFromUrl : ""
);
const [searchResults, setSearchResults] = useState([] as any[]);

const [hasCompleted, setHasCompleted] = useState(false);

async function getResponse() {
// query should be the URL that your search will be executed on.
const query = `http://localhost:3000/api/search?query=${searchString}`;
const response = await fetch(query, {
method: "GET",
});

const data = await response.json(); // Extracting data as a JSON Object from the response
setSearchResults(data);
setHasCompleted(true);
}

const handleClickUser = async () => {
setHasCompleted(false);
if (searchString === "" || searchString.trim() === "") return;
getResponse();
router.push({
pathname: "../search",
query: { query: searchString },
});
};

useEffect(() => {
if (searchString !== "") {
handleClickUser();
}
}, []);

console.log(searchResults);

return (
<div className="pageWrapper">
<h1>Search Page</h1>
<SearchInput
value={searchString}
onChange={(e) => setSearchString(e.target.value)}
onClick={handleClickUser}
/>
{hasCompleted && (
<div className="wrapper">
{searchResults.map((result) => getCardByType(result))}
</div>
)}
</div>
);
};

export default SearchPage;

Repo for finished project: https://github.com/peterrolfsen/SanityGlobalSearch

--

--