Mimic Googles user friendly search results with Sanity, React and Next JS

Peter Rolfsen
6 min readNov 4, 2023

--

In this tutorial. We will look at how to mimic Googles user friendly search results.

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

This tutorial builds on the codebase of one of my previous tutorials.
Advanced (deep) global search with Sanity, NextJs and React.

What we will learn

  • Find where the search word is in the content and retrieve that sentence to create an excerpt for the search result.
  • Manipulate the search word in the search results to be highlighted for better user experience.

The goal is to create a searchresult which looks like this:

1. Finding and storing the new excerpt

In the code below we store the access to my two blockContent fields in variables. I only have two blockContent fields in my project. bodyQuery and bioQuery. I then mast them with the queryString and add + ‘*’ behind.

+ '*'

This allows me to not be limited to searching for the complete word. I can start on a word. For instance ‘pos’ and it would return every word starting with ‘post. If you want it to respons to the end and middle of the word. Also add this code in front of queryString

I define a variable searchedQuery in the return section of the GROQ. This is where I will store the text from the searched content if it is found.
I use the function select(). This selects the first instance that returns true inside it.

In my query. I want it to return the first occurrence of the search word, so that I want return it. I then store it in a variable called cardExcerpt.

'searchedQuery': select(
${bodyQuery} match $queryString + '*' =>{
'cardExcerpt': ${bodyQuery},
},
${bioQuery} match $queryString + '*' =>{
'cardExcerpt': ${bioQuery},
},
description match $queryString + '*' =>{
'cardExcerpt': description,
},
),

I create as many checks as I have content I want to check.

Beneath is the full search query code.

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'] &&
(
${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,
'searchedQuery': select(
${bodyQuery} match $queryString + '*' =>{
'cardExcerpt': ${bodyQuery},
},
${bioQuery} match $queryString + '*' =>{
'cardExcerpt': ${bioQuery},
},
description match $queryString + '*' =>{
'cardExcerpt': description,
},
),

}
`;

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

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

For further explanation into how to access blockContent in Sanity, such as done in this file. Please check out this article on deep GROQ search.

2. Manipulating and presenting the search string in the search results

My search landing page is barely modified. The difference here from the previous project is that I now pass searchString into getCardByType. I do this because I am going to run a check in getCardByType to see if the search queries excerpt contains it.

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:3001/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, searchString))}
</div>
)}
</div>
);
};

export default SearchPage;

In getCardByType we add the new parameter searchString, and create the function processExcerpt(). This function takes in the parameter data, which is the object that is card and the searchString.

It then destructures cardExcerpt from data to retrieve the match we made in the GROQ earlier. If it is only a text field or a single line body, cardExcerpt will be a string. If it is a multiline blockContent field, it will be an array.

If it is an array, we find the first instance of the searchString in excerpt using the find(function) and sets the variable modifiedExcerpt to be the sentence that first contains the searchString.

We then use the replacefunction on this modifiedExcerpt to replace searchString with searchString wrapper in bold.

If excerpt is a string. We fasttrack to the replace step.

const processExcerpt = (
data: { searchedQuery: { cardExcerpt: string } },
searchString: string
) => {
const excerpt = data?.searchedQuery?.cardExcerpt;
if (Array.isArray(excerpt)) {
const modifiedExcerpt = excerpt.find((cardExcerpt) =>
cardExcerpt?.toLowerCase().includes(searchString?.toLowerCase())
);

if (modifiedExcerpt) {
return `${modifiedExcerpt
.replace(
new RegExp(searchString, "gi"),
(match: any) => `<strong>${match}</strong>`
)
.slice(0, 200)}...`;
}
} else if (excerpt) {
return `${excerpt
.replace(
new RegExp(searchString, "gi"),
(match) => `<strong>${match}</strong>`
)
.slice(0, 200)}...`;
}
return "";
};

Now in order to modify the HTML and add the strong tags. We have to use dangerouslySetInnerHTML as the excerpt attribute of Card.

<Card
title={title}
excerpt={
data?.searchedQuery?.cardExcerpt ? (
<span
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: processExcerpt(data, searchString),
}}
/>
) : (
<PortableText value={body} />
)
}
type={type}
slug={slug}
key={slug}
ptComponents={ptComponents}
/>

Since we do not want to unnecessarily use dangerouslySetInnerHTML, we check if cardExcerpt exists. If not, we return the regular body text.
It could be wise to slice it in order to contain the length of the new excerpt.

import { PortableText } from "@portabletext/react";
import Card from "../components/Card/Card";

export const getCardByType = (data: any, searchString: string) => {
const { title, body, type, slug, ptComponents, description, bio, name } =
data;
const processExcerpt = (
data: { searchedQuery: { cardExcerpt: string } },
searchString: string
) => {
const excerpt = data?.searchedQuery?.cardExcerpt;
if (Array.isArray(excerpt)) {
const modifiedExcerpt = excerpt.find((cardExcerpt) =>
cardExcerpt?.toLowerCase().includes(searchString?.toLowerCase())
);

if (modifiedExcerpt) {
return `${modifiedExcerpt
.replace(
new RegExp(searchString, "gi"),
(match: any) => `<strong>${match}</strong>`
)
.slice(0, 200)}...`;
}
} else if (excerpt) {
return `${excerpt
.replace(
new RegExp(searchString, "gi"),
(match) => `<strong>${match}</strong>`
)
.slice(0, 200)}...`;
}
return "";
};

switch (type) {
case "post":
return (
<Card
title={title}
excerpt={
data?.searchedQuery?.cardExcerpt ? (
<span
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: processExcerpt(data, searchString),
}}
/>
) : (
<PortableText value={body} />
)
}
type={type}
slug={slug}
key={slug}
ptComponents={ptComponents}
/>
);

case "author":
return (
<Card
title={name}
excerpt={
data?.searchedQuery?.cardExcerpt ? (
<span
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: processExcerpt(data, searchString),
}}
/>
) : (
<PortableText value={bio} />
)
}
type={type}
slug={slug}
key={slug}
ptComponents={ptComponents}
/>
);
case "category":
return (
<Card
title={title}
excerpt={
data?.searchedQuery?.cardExcerpt ? (
<span
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: processExcerpt(data, searchString),
}}
/>
) : (
description
)
}
type={type}
slug={slug}
key={slug}
ptComponents={ptComponents}
/>
);

default:
return "hello";
}
};

export default getCardByType;

This will now give us the result of highlighting all the instances of the searched string in our search result!

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

Follow for more tips!

--

--