Type something to search...
How to Write Full-Text Search Queries in GROQ

How to Write Full-Text Search Queries in GROQ

To write full-text search queries in GROQ, use the match operator in a filter, such as *[_type == "post" && title match "groq*"]. match splits text into words, ignores case, and supports * wildcards, so "groq*" finds "GROQ", "groq-powered" and "GROQs". Search several fields at once with an array on the left, like [title, pt::text(body)] match $term, and rank results by relevance by piping them through score() with boost() on important fields, then order(_score desc).

A search box is one of the most useful features you can add to a content site, and with Sanity you don't need a separate search service to get started. GROQ's built-in text matching covers most blog, documentation and product searches. This guide explains how match tokenises text, how wildcards and multiple terms behave, how to search Portable Text, how to parse user search strings with text::query(), how to rank results with score() and boost(), and how to wire it all into a Next.js search page.

How match Works

match compares words, not substrings. Both sides are split into tokens on whitespace and punctuation, and each pattern on the right must match a token on the left.

*[_type == "post" && title match "sanity"]

This finds "Getting started with Sanity" and "SANITY tips", but not "Insanity" because "sanity" isn't a whole word there.

The tokenisation rules are worth knowing:

RuleExample
Case is ignored"CONFIGURA" match "configura" is true
Hyphens, dots and @ split words"sci-fi" becomes sci and fi
Brackets are stripped"Pixel (9)" matches "Pixel 9"
Accents are kept"configurá" doesn't match "configura"
No stemming"run" doesn't match "running" without a wildcard

The lack of stemming is the main thing to plan for. "Run" and "running" are different words to match, so you'll usually add a wildcard to the user's terms.

Wildcards

* matches any characters within a word:

// Prefix: groq, groqd, GROQs
*[_type == "post" && title match "groq*"]

// Suffix: configuration, migration
*[_type == "post" && title match "*ation"]

// Inside a word: words starting with "cont" and ending with "er"
*[_type == "post" && title match "cont*er"]

Prefix wildcards are the most useful for search-as-you-type, because they let a partial word like "port" match "Portable Text".

Multiple Terms

When the right-hand side has several words, every word must match somewhere in the field:

// Both "portable" and "text" must appear, in any order
*[_type == "post" && title match "portable text"]

This is AND logic, and the words don't have to be next to each other. "text portable" matches the same documents. You can also pass an array of patterns, which behaves the same way:

*[_type == "post" && title match ["portable", "text*"]]

Searching Several Fields

Put the fields in an array on the left. A document matches if the term is found in any of those fields:

*[_type == "post" && [title, excerpt, seo.description] match $term]

One caveat from Sanity's operator reference: with an array on the left and several words on the right, all the words currently need to match within a single field, not spread across different fields. A post with "portable" in the title and "text" only in the body won't match "portable text". Use a parameter for the term so user input never becomes part of the query string. Using GROQ parameters safely explains why.

Searching Arrays of Strings

To search an array field such as tags, traverse it with []:

*[_type == "post" && tags[] match "react*"]

Sanity's documentation lists a known issue where matching directly on an array attribute, without the [], never matches. Writing tags[] avoids it.

Searching Portable Text

Rich text fields are arrays of blocks, not strings. Convert them to plain text with pt::text() before matching:

*[_type == "post" && pt::text(body) match $term]

pt::text() reads the text of top-level blocks and ignores custom blocks like images or code, which is usually what you want for search. If you're unfamiliar with the structure, see what Portable Text is.

Combining everything:

*[_type == "post" && [title, excerpt, pt::text(body)] match $term] {
  _id, title, "slug": slug.current
}

Parsing User Input With text::query()

Users type things like "portable text" -react and expect a phrase search with an exclusion. text::query() parses a search string with that kind of syntax and returns something match can use:

*[_type == "post" && [title, pt::text(body)] match text::query($q)]

According to Sanity's function reference, the supported syntax is:

InputMeaning
draculaA single word
"Google Pixel 9"An exact phrase, word order matters
go*Prefix wildcard
*ionSuffix wildcard
"find o*"Phrase with a wildcard on the last word
-proExclude documents containing the word
-"exact phrase"Exclude a phrase

Wildcards in the middle of a phrase aren't supported and return no results. Outside score(), multiple words use AND logic, just like plain match.

This is the main reason to prefer text::query() over a plain string: real phrase matching. A plain match "google pixel" finds both words anywhere in the field, while text::query("\"google pixel\"") requires them together, in order. Check the GROQ functions reference for the API version your client needs, since newer functions may not be available on older apiVersion dates.

Ranking Results With score() and boost()

Filters only decide which documents match. To sort by relevance, use the score() pipe function. It adds a _score to each document based on how many of its expressions match:

*[_type == "post"]
  | score(
      title match $term,
      excerpt match $term,
      pt::text(body) match $term
    )
  | order(_score desc)
  { _id, title, "slug": slug.current, _score }
  [_score > 0]

Two things to notice:

  • Non-matching documents stay in the results with a _score of 0. The final [_score > 0] removes them.
  • Order of pipes: score() goes after the filter and before the projection, then you sort by _score.

Weighting Fields With boost()

A match in the title should count for more than a match in the body. boost() multiplies an expression's contribution:

*[_type == "post"]
  | score(
      boost(title match $term, 4),
      boost(excerpt match $term, 2),
      pt::text(body) match $term,
      boost(featured == true, 1)
    )
  | order(_score desc, publishedAt desc)
  { _id, title, "slug": slug.current, _score }
  [_score > 0]

Values above 1 increase an expression's weight and values between 0 and 1 reduce it. boost() accepts only constant positive numbers, so you can't boost by a field value. Notice the featured boost: you can score on any boolean expression, not only text matches, which is a simple way to nudge important content up. Adding publishedAt desc as a second sort key breaks ties in favour of newer posts.

Note the subtlety with that featured boost: because score() keeps every document, a featured post with no text match would still get a positive score and survive the [_score > 0] filter. If that's not what you want, add the text conditions to the filter as well, so only matching documents are scored.

text::query() Inside score()

When you use text::query() inside score(), multiple words switch from AND to OR logic, so any matching term contributes to the rank. That gives you a forgiving search where documents matching more words rank higher:

*[_type == "post"]
  | score(
      boost(title match text::query($q), 3),
      pt::text(body) match text::query($q)
    )
  | order(_score desc)
  { title, _score }
  [_score > 0]

Building a Search Page in Next.js

Here's a server-rendered search page at /search?q=... using the App Router:

// src/sanity/queries.ts
import { defineQuery } from 'next-sanity'

export const SEARCH_QUERY = defineQuery(`
  *[_type == "post" && defined(slug.current) && [title, excerpt, pt::text(body)] match $term]
    | score(
        boost(title match $term, 4),
        boost(excerpt match $term, 2),
        pt::text(body) match $term
      )
    | order(_score desc, publishedAt desc)
    [0...20]
    { _id, title, excerpt, "slug": slug.current }
`)
// src/app/search/page.tsx
import Link from 'next/link'
import { client } from '@/sanity/client'
import { SEARCH_QUERY } from '@/sanity/queries'

function toTerm(q: string) {
  // Turn "portable tex" into "portable* tex*" so partial words match
  return q
    .trim()
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 6)
    .map((word) => `${word}*`)
    .join(' ')
}

export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>
}) {
  const { q = '' } = await searchParams
  const term = toTerm(q)
  const results = term ? await client.fetch(SEARCH_QUERY, { term }) : []

  return (
    <main>
      <form action="/search">
        <input type="search" name="q" defaultValue={q} placeholder="Search posts" />
        <button type="submit">Search</button>
      </form>

      {q && <p>{results.length} results for "{q}"</p>}

      <ul>
        {results.map((post) => (
          <li key={post._id}>
            <Link href={`/blog/${post.slug}`}>{post.title}</Link>
            {post.excerpt && <p>{post.excerpt}</p>}
          </li>
        ))}
      </ul>
    </main>
  )
}

The query filters to matching documents first, so score() only ranks real matches and the [_score > 0] step isn't needed. The toTerm() helper adds a prefix wildcard to each word and caps the number of words, which keeps queries reasonable if someone pastes a paragraph into the box.

When GROQ Search Isn't Enough

GROQ's match is a solid fit for most sites, but it has limits:

  • No stemming or synonyms: "colour" won't find "color", and "mouse" won't find "mice".
  • No typo tolerance: "sanitty" finds nothing.
  • Word boundaries only: substring searches inside words need wildcards.
  • Accent-sensitive: "cafe" and "café" are different words.

Sanity also offers semantic search through text::semanticSimilarity(), which ranks documents by meaning. It's only valid inside score() and requires embeddings to be enabled on the dataset, so check your plan and dataset settings before relying on it. If you need typo tolerance and faceting, a dedicated search service fed by Sanity webhooks is the usual next step.


FAQ: Full-Text Search in GROQ

No. match ignores case, so "GROQ", "groq" and "Groq" all match each other. It does keep accents, so words with and without diacritics are treated as different.

match compares whole words, not substrings. Add a wildcard to match part of a word, for example "port*" to find "Portable", or "*text" to find words ending in text.

Convert the field to plain text with pt::text() and match against that, for example pt::text(body) match $term. It reads the text of standard blocks and ignores custom blocks such as images.

Pipe the results through score() with the match expressions you care about, wrapping important ones in boost(), then sort with order(_score desc). Filter with _score > 0 at the end if you didn't already filter to matching documents.

Plain match treats a phrase as a set of words that can appear anywhere. For an exact phrase in order, use text::query() with the phrase in double quotes, which also supports negation with a minus sign.

No. match is designed for natural-language text and splits on hyphens and dots, which breaks identifiers apart. Use equality or string::startsWith() for slugs, IDs, emails and file names.


Conclusion

GROQ gives you a capable search engine without leaving Sanity. Use match with wildcards for word and prefix matching, search several fields at once with an array, flatten rich text with pt::text(), and parse user-friendly syntax with text::query() when you need phrases or exclusions. Then rank results with score() and boost() so the most relevant documents come first.

Test your search queries in the Vision plugin with real terms, keep the term in a parameter, and add a prefix wildcard to user input. If you need more filtering around search results, the guide to filtering and sorting with GROQ covers the rest of the query.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper