D
P
0

Next.js

Sanity Panel 500s in Prod, API Returns 400 `score() function received unexpected expression`? boost() Only Takes Simple Predicates

July 9, 2026·4 min read
Sanity Panel 500s in Prod, API Returns 400 `score() function received unexpected expression`? boost() Only Takes Simple Predicates

I was adding a small panel to a client's Sanity Studio for a content site: a "Link Suggestions" plugin that surfaces related articles while an editor is writing. The idea is simple. The editor types a few words, the panel runs a scored GROQ query, and the most relevant articles float to the top. Locally, against a tiny dataset, it worked fine. I shipped it to production, clicked the panel for the first time against the real data, and it immediately 500'd.

My first instinct blamed React: maybe the panel component threw, maybe there was some unhandled state. But the moment I opened the Network tab, it was clearly not the frontend. The request to the data API came back as a raw HTTP 400, not a 500 from the component. The error payload was a single line, but that line unravelled everything:

score() function received unexpected expression

Why this happens

I had assumed score() and boost() were ordinary GROQ expressions, the same as any other clause in the query. They are not. GROQ's scoring engine runs a much more restricted parser than the main GROQ parser. Inside boost(), you are only allowed simple predicates: a direct field path plus a match or comparison operator. That is it. No function calls.

The problem was that my query was full of function calls, because they seemed reasonable. An article title could live on either the title field or the name field, so I wrapped it in coalesce(). The body is Portable Text, so to match its text I reached for pt::text(). Both are perfectly valid GROQ functions, and both work fine in a projection clause. What I did not realize: valid in a projection does not mean valid inside boost().

// WRONG — coalesce() and pt::text() are rejected inside boost()
*[_type == "article"] | score(
  boost(coalesce(title, name) match $q, 2),
  boost(pt::text(body) match $q, 1)
)

The instant that query hit the data API, the scoring parser saw a function call where it only expected a simple predicate and rejected the whole expression with a 400. Locally I got away with it because my dataset was small and I never really exercised the heavy scoring path; production triggered it on the first request. Not a React bug, not a deploy problem. My query was simply invalid for the scoring engine from the start.

The fix

The fix was not to drop scoring, but to break every function down into a simple predicate that boost() can digest. For coalesce(title, name), I stopped trying to fold two fields into one expression. I split it into two separate predicates, each wrapped in its own boost() with the same weight:

*[_type == "article"] | score(
  boost(title match $q, 2),
  boost(name match $q, 2)
)

For the Portable Text body, I replaced pt::text(body) with an explicit array-traversal path straight to its text nodes. Portable Text is an array of blocks, each block has a children array, each child has a text field. So instead of calling a function to flatten the body into a string, I skipped the function and matched directly against the field path:

*[_type == "article"] | score(
  boost(title match $q, 2),
  boost(name match $q, 2),
  boost(body[].children[].text match $q, 1)
)

body[].children[].text is a pure field path, so the scoring parser is happy. Worth noting: coalesce() itself was never wrong, it was just in the wrong place. I kept coalesce(title, name) in the projection clause to pick which title to show on the result card. The only thing that changed is this: functions are strictly banned inside boost(), but free to use in the projection. After splitting it out this way, the API returned 200, the panel rendered, and related articles floated up with the correct weighting.

The takeaway

score() and boost() in GROQ are not general-purpose expressions. They run on a restricted parser that only accepts simple predicates, so anything more than a field path plus match or a comparison gets rejected with score() function received unexpected expression. If you are stuck, ask one thing: am I calling a function inside boost()? If yes, split it. Combine multiple fields with several boost() calls instead of one coalesce(), and replace pt::text() with an array-traversal path like body[].children[].text. Save the function calls for the projection clause, where they are genuinely valid. And exercise the scoring path against a dataset large enough to trigger it before shipping, because a tiny local dataset can hide a 400 waiting on production's very first request.