Type something to search...
Using GROQ Parameters Safely in Your Queries

Using GROQ Parameters Safely in Your Queries

GROQ parameters are named placeholders, written with a dollar sign such as $slug, that you put in a query and fill with values sent separately. Instead of building *[slug.current == "' + slug + '"] by joining strings, you write *[_type == "post" && slug.current == $slug][0] and pass { slug: "hello-world" } as the second argument to client.fetch(). Sanity treats the parameter as a value, never as query syntax, so user input can't change the shape of your query, and the query string stays the same for every request, which makes it easier to read, type and cache.

Almost every real Sanity query depends on something that changes at runtime: a slug from the URL, a search term, a page number or a language. How you get those values into the query matters for security, correctness and performance. This guide covers the parameter syntax, how to pass values with @sanity/client and next-sanity, which value types are allowed, what parameters can't do, and the patterns that keep dynamic queries safe.

Why String Concatenation Is a Problem

It's tempting to build a query with a template literal:

// Don't do this
const query = `*[_type == "post" && slug.current == "${slug}"][0]`;

This works until the value contains something you didn't expect. A slug with a double quote breaks the query with a parse error. Worse, if slug comes from a URL or a form, someone can inject GROQ. A value like x" || true || " turns the filter into one that matches every post, and a crafted value can pull in fields or documents you never meant to expose.

GROQ is read-only, so injection can't delete data, but it can still leak it. If your client uses a token that can read drafts or private documents, an injected filter could return them to the browser.

String building has quieter costs too:

  • Escaping bugs: you have to handle quotes, backslashes and non-ASCII characters yourself.
  • Type loss: a number interpolated into a string query needs care to stay a number, and arrays or objects need manual serialising.
  • Poorer caching: each different value produces a different query string, so you lose the benefit of one stable query that tools like Sanity TypeGen and your framework's cache can recognise.

Parameters solve all of these at once.

GROQ Parameter Syntax

A parameter is an identifier prefixed with $. It can appear anywhere GROQ expects a value: in filters, comparisons, slices, function arguments and projections.

*[_type == "post" && slug.current == $slug][0]{
  title,
  publishedAt
}

You can use as many parameters as you need, and use the same one more than once:

*[_type == $type && language == $language && publishedAt <= $now]
  | order(publishedAt desc)[$start...$end]{
    title,
    "slug": slug.current
  }

A few rules apply to names:

  • Identifiers only: names follow the same rules as other GROQ identifiers, so stick to letters, numbers and underscores, starting with a letter or underscore.
  • Every parameter must be supplied: if the query references $slug and you don't pass it, the API returns an error rather than treating it as null.
  • No $ in the params object: when you pass values from JavaScript or a JSON body, you use the plain name (slug), not $slug.

Passing Parameters With @sanity/client

The second argument to client.fetch() is the params object:

// src/sanity/client.ts
import { createClient } from '@sanity/client';

export const client = createClient({
  projectId: 'your-project-id',
  dataset: 'production',
  apiVersion: '2025-02-19',
  useCdn: true,
});
import { client } from './sanity/client';

const query = `*[_type == "post" && slug.current == $slug][0]{
  title,
  "slug": slug.current,
  publishedAt
}`;

const post = await client.fetch(query, { slug: 'hello-world' });

The client encodes each value as JSON and sends it with the request. For GET requests the values become URL parameters such as $slug="hello-world". For long queries the client switches to a POST request automatically and sends query and params in the JSON body. You don't have to handle either case yourself.

If you're new to setting up the client, the guide to your first Sanity project walks through projectId, dataset and apiVersion.

With next-sanity in the App Router

next-sanity re-exports createClient and defineQuery. Using defineQuery lets Sanity TypeGen generate result types for the query, and the params stay the same:

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

export const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{
  title,
  "slug": slug.current,
  body
}`);
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { client } from '@/sanity/client';
import { POST_QUERY } from '@/sanity/queries';

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await client.fetch(POST_QUERY, { slug });

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
    </article>
  );
}

Note that the route's params is a promise in current Next.js, so you await it before passing the slug on. The name clash is a coincidence: Next.js route params and GROQ params are separate things, but it's common to forward one into the other.

Allowed Parameter Values

Parameter values are JSON, so you can pass any JSON type:

JavaScript valueGROQ seesTypical use
'hello-world'StringSlugs, IDs, search terms, language codes
10NumberSlice bounds, limits, ratings
trueBooleanFeature flags inside filters
nullnullOptional values combined with defined() checks
['news', 'tips']ArrayMatching against a list with in
{ min: 1, max: 5 }ObjectGrouped values accessed as $range.min

Dates have no special JSON type, so send them as ISO strings and compare them with date-time strings in GROQ, or wrap both sides in dateTime():

const query = `*[_type == "event" && dateTime(startsAt) > dateTime($now)]
  | order(startsAt asc)`;

const events = await client.fetch(query, { now: new Date().toISOString() });

Arrays work well with the in operator:

*[_type == "post" && category->slug.current in $categories]{
  title
}
await client.fetch(query, { categories: ['news', 'tutorials'] });

The reference in that filter is followed with ->, which is covered in how to dereference documents in GROQ.

Parameters in Slices and Pagination

Slice bounds accept parameters, which is the standard way to paginate:

*[_type == "post"] | order(publishedAt desc)[$start...$end]{
  title,
  "slug": slug.current
}
const pageSize = 10;
const page = 2;

const posts = await client.fetch(query, {
  start: (page - 1) * pageSize,
  end: page * pageSize,
});

Make sure the values are numbers. A page number read from a query string arrives as a string, and "10" is not a valid slice bound. Convert and validate it first:

const raw = Number(searchParams.page ?? '1');
const page = Number.isInteger(raw) && raw > 0 ? raw : 1;

For cursor-based pagination and the performance trade-offs of large offsets, see pagination in Sanity with GROQ.

What Parameters Can't Do

Parameters stand in for values only. They can't replace parts of the query's structure:

  • Field names: *[_type == "post"] | order($field desc) sorts by the constant string value, not by the field it names, so every result ties.
  • Attribute access: bracket access like object["attribute"] only accepts a literal, so you can't use a parameter to pick a field dynamically.
  • Operators or keywords: you can't pass asc or desc, && or ||, or a function name.
  • Whole filters or projections: a parameter can't hold a chunk of GROQ to be evaluated.

When you need dynamic structure, build it in code from a fixed list of allowed options, then use parameters for any values:

const SORTS = {
  newest: 'publishedAt desc',
  oldest: 'publishedAt asc',
  title: 'title asc',
} as const;

type SortKey = keyof typeof SORTS;

export function postsQuery(sort: string) {
  const order = SORTS[sort as SortKey] ?? SORTS.newest;
  return `*[_type == "post" && category->slug.current == $category]
    | order(${order})[0...20]{ title, "slug": slug.current }`;
}

The user picks a key, never the GROQ itself. Anything they type, like the category, still goes through a parameter. This allowlist approach keeps the flexibility without opening the door to injection.

If you'd rather avoid any interpolation, write one query per sort order. It's more text, but each query is static, readable in Vision and fully typed by TypeGen.

Optional Parameters

Since every referenced parameter must be supplied, optional filters need a little thought. One clean pattern is to pass null and check it in the filter:

*[_type == "post"
  && (!defined($category) || category->slug.current == $category)
] | order(publishedAt desc)[0...20]{
  title
}
await client.fetch(query, { category: searchParams.category ?? null });

When $category is null, defined($category) is false, so the first half of the condition is true and the filter is skipped. When a category is supplied, only matching posts are returned. The GROQ functions guide covers defined() and friends in more detail.

Parameters and Search

Search terms are the most important place to use parameters, because they're free text typed by visitors:

*[_type == "post" && (title match $term || pt::text(body) match $term)]
  | score(title match $term)
  | order(_score desc)[0...10]{
    title,
    "slug": slug.current
  }
const term = searchParams.q?.trim();
const results = term
  ? await client.fetch(query, { term: `${term}*` })
  : [];

Adding the * wildcard in code, inside the parameter value, gives prefix matching without touching the query. Trimming and checking for an empty string avoids a query that matches everything. The full set of techniques is in how to write full-text search queries in GROQ.

Testing Parameters in Vision

The Vision tool in Sanity Studio has a Params panel next to the query editor. Enter the values as JSON, using the names without the $:

{
  "slug": "hello-world",
  "start": 0,
  "end": 10
}

Vision then runs the query exactly as your app would. If you forget a parameter, you'll see the same error your code would get, which makes it a good place to check optional-parameter logic before shipping. The guide to debugging GROQ queries with Vision shows how to set it up.

Practical Safety Checklist

  • Always parameterise user input: slugs, search terms, IDs, filters and page numbers from URLs or forms.
  • Validate types before sending: convert numbers, trim strings and reject values outside the expected range.
  • Allowlist structure: if the user chooses a sort order or field, map their choice to a fixed set of GROQ snippets.
  • Use the least powerful token: public pages should use an unauthenticated client or a read-only token with the published perspective, so even a bad query can't return drafts.
  • Keep secrets server-side: tokens belong in server-only environment variables, never in client components.
  • Keep queries static: define them once with defineQuery so they're typed, reusable and easy to review.

FAQ: GROQ Parameters

Reference it in the query with a dollar sign, such as $slug, then pass an object as the second argument to client.fetch(), with a slug property set to the value you want. Use the plain name without the dollar sign in the object.

Yes, for values. Sanity treats each parameter as a JSON value rather than query text, so quotes or operators in user input can't change the query. You still need to validate input and avoid interpolating user choices into the query structure.

No. A parameter is always a value, so order($field) sorts by a constant string. Map the user's choice to a fixed set of sort expressions in your code and insert the matching one into the query.

The query fails with an error saying the parameter is not defined. If a filter is optional, pass null and guard it with defined() in the query instead of leaving the parameter out.

Yes. Parameter values can be any JSON type, so you can pass an array to use with the in operator or an object whose properties you access with dot notation, such as $range.min.

For GET requests, each parameter is a URL parameter with a JSON-encoded value, such as $slug="hello-world". For POST requests, they go in a params object in the JSON body, without the dollar prefix. @sanity/client handles both for you.


Conclusion

GROQ parameters are the safe, standard way to get runtime values into a query. Write $name where the value belongs, pass the values as an object to client.fetch(), and let Sanity handle the encoding. You avoid injection and escaping bugs, keep proper types for numbers, arrays and booleans, and end up with static queries that TypeGen and Vision can work with.

Remember the one limit: parameters replace values, not structure. When users need to choose a sort order or field, map their choice to an allowlist in code. Combine that with input validation and a read-only client for public pages, and your dynamic queries will be both flexible and safe.

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