Eric
Howey.

There is a hidden page you can visit at /you-are-awesome. Didn't want you to miss out on the fun!

Searching content with Sanity, Next.js, and GROQ

Okay I know I just said technical blog posts are dead BUT this was a recent coding challenge I had to sort out that AI really struggled to assist with. So here goes!

On a recent client project I had to implement some basic search and filtering functionality. Now this is something you could absolutely offload to a SaaS like Algolia, but in our use case it didn’t make sense to add yet another external service to the project architecture.

We needed something basic and good-enough, but we did not need Google level search optimization.

GROQ to the rescue

Sanity has their own query language they call GROQ. At its most basic it allows you to easily query content in your database but under the hood it offers a lot of powerful functionality — filter, order, join, and yes, even search.

// Search for the word cookie in recipes
*[recipe match "cookie"]

Yes it is that easy. But in a real project this is really a bit too rudimentary to be useful in most cases.

Fortunately GROQ gives us the power to score our query and control how that score is calculated. When combined with ordering the search results based on score we can massage the results to show users the most useful information.

Here is what that would look like:

*[_type == "recipe"]
    | score(
        boost(title match $searchTerm, 3) ||
        boost(description match $searchTerm, 2) ||
        pt::text(body) match $searchTerm
    )
    | order(_score desc)
    { _score, title, "slug": slug.current, description }
    [ _score > 0 ]

Lots more happening now! Let’s break it down and talk about what each line is doing here.

  • *[_type == "recipe"]: Search all recipes in the database
  • boost(title match $searchTerm, 3): A match for the search term in the title is worth three points (and two points in the description)
  • pt::text(body) match $searchTerm: A match in the body text is worth one point by default
  • order(_score desc): Order the results by score, highest to lowest
  • { _score, title, "slug": slug.current, description }: Return just what you need to speed up the query
  • [ _score > 0 ]: Limit the results to only recipes with a score greater than 0

Effectively by scoring the search query we are weighting the results towards results that have the search term in the title or description as these are more likely to be relevant to the user’s search.

Translating into Next.js with server actions

There are more than a few right ways to do this. But what I have found works best is using a form with a server action to submit the request.

I won’t cover setting up a basic search form, likewise the code below is a simplified version of the server action but you can see the full code for it in the project demo here with some basic input validation and sanitization.

In React 19 useFormState was renamed to useActionState, it also resets forms automatically after submission. For more information: https://github.com/facebook/react/issues/29034

actionSearch.ts
'use server'
import { ActionSearchResult } from '@/types'
import { client } from '@/sanity/lib/client'
import { PostsQueryResult } from '@/sanity.types'
 
export const actionSearch = async (
  prevState: any,
  formData: FormData,
): Promise<ActionSearchResult> => {
  // Get the search term
  const search = formData.get('search') as string | null
  //   GROQ query and params to search posts
  const query = `
    *[_type == "post"]
        | score(
            boost(title match $search, 3) || 
            boost(excerpt match $search, 2) || 
            pt::text(content) match $search
        )
        | order(_score desc)
        { _score, _id, title, "slug": slug.current, excerpt, date, author}
        [ _score > 0 ]
    `
  // Fetch results from Sanity
  try {
    const results: PostsQueryResult = await client.fetch(query, { search })
    return {
      status: 'success',
      message: 'Search completed successfully',
      results: results,
    }
  } catch (error) {
    console.error('Unable to complete submission:', error)
    return {
      status: 'error',
      message: 'Sorry! Something went wrong, try again.',
      results: null,
    }
  }
}

Then with the useActionState hook from React you can “watch” for the results from the form submission/server action and react accordingly. In this case if the form status is success we will update the search results, if it is an error we are just console logging the error. In production you may need to consider a few other things here like popups for the user if there is an error and setting url parameters.

const [formState, formAction] = useActionState(actionSearch, initialState)
 
useEffect(() => {
  if (formState && formState.status === 'error') {
    console.log(formState.message)
  }
  if (formState && formState.status === 'success') {
    setSearchResults(formState.results)
  }
}, [formState])

Moving to production

Here are a few things you may want to consider when rolling this out to production:

  • Sanitize user inputs: Never trust user inputs! You should do some basic input sanitization on the server to protect your data.
  • URL params: In this basic demo we are not storing the search query as a url parameter. But in production you would potentially want to add this to your app to allow users to share a query result and better accessibility.
  • Controlled input: As noted above React 19 currently resets forms automatically after submission, which can cause some problems. In the case of this search input it would be nice if the search term remained in the input field until the user manually cleared or reset the search.
  • Spelling mistakes: Right now the basic search functionality does not account for simple spelling errors (looking at you tommorow), you could add a threshold-based spellchecker to the server action to auto-correct user inputs.
  • Rate-limiting your server actions: It is always a good thing to protect API endpoints from malicious attacks. One way you can accomplish this is via rate-limiting. Vercel has a guide for doing this on their platform.
  • Passing data via hidden form fields: You may need to pass other context data, like a user’s locale for example, to the server action. An easy way to do this is via a hidden input with a default value. This can be used to further refine the search results a user sees.

So there you have it, a simpler search input using Next.js, Sanity, and the GROQ query language. Is it as a good as a dedicated Saas would be? No. Is it good-enough for a lot of use cases? Yep!

Happy coding!