Handling checkbox filters in a NextJS app with Groq queries and React state

25 replies
Last updated: Aug 10, 2022
I'm trying to create a checkbox filter in my NextJS app. How would I handle filtering out sanity documents that don't meet the checkbox requirements? (I would post code but I don't even know where to begin)
AI Update

I'll help you understand how to filter Sanity documents based on checkbox inputs in your Next.js app! This is a common pattern that combines client-side state management with GROQ queries.

The Basic Approach

The key is to build dynamic GROQ queries based on your checkbox state. Here's a practical example:

1. Set up your checkbox state:

// components/FilteredPosts.jsx
import { useState, useEffect } from 'react';
import { client } from '@/sanity/lib/client';

export default function FilteredPosts() {
  const [filters, setFilters] = useState({
    featured: false,
    published: false,
    hasImage: false
  });
  const [posts, setPosts] = useState([]);

  // Handle checkbox changes
  const handleFilterChange = (filterName) => {
    setFilters(prev => ({
      ...prev,
      [filterName]: !prev[filterName]
    }));
  };

  // Fetch filtered posts
  useEffect(() => {
    const fetchPosts = async () => {
      const query = buildQuery(filters);
      const result = await client.fetch(query);
      setPosts(result);
    };
    
    fetchPosts();
  }, [filters]);

  return (
    <div>
      <div className="filters">
        <label>
          <input
            type="checkbox"
            checked={filters.featured}
            onChange={() => handleFilterChange('featured')}
          />
          Featured Only
        </label>
        <label>
          <input
            type="checkbox"
            checked={filters.published}
            onChange={() => handleFilterChange('published')}
          />
          Published Only
        </label>
        <label>
          <input
            type="checkbox"
            checked={filters.hasImage}
            onChange={() => handleFilterChange('hasImage')}
          />
          Has Image
        </label>
      </div>
      
      <div className="posts">
        {posts.map(post => (
          <div key={post._id}>{post.title}</div>
        ))}
      </div>
    </div>
  );
}

2. Build dynamic GROQ queries:

function buildQuery(filters) {
  // Start with base query
  let conditions = ['_type == "post"'];
  
  // Add conditions based on active filters
  if (filters.featured) {
    conditions.push('featured == true');
  }
  
  if (filters.published) {
    conditions.push('publishedAt != null');
  }
  
  if (filters.hasImage) {
    conditions.push('defined(mainImage)');
  }
  
  // Join conditions with && operator
  const filterString = conditions.join(' && ');
  
  // Return complete GROQ query
  return `*[${filterString}] | order(publishedAt desc) {
    _id,
    title,
    slug,
    publishedAt,
    featured,
    mainImage
  }`;
}

Key GROQ Filtering Concepts

Based on GROQ's syntax structure, here are the filtering patterns you'll use:

  • Equality checks: fieldName == true or fieldName == "value"
  • Existence checks: defined(fieldName) - checks if a field exists (uses the defined() function)
  • Null checks: fieldName != null - ensures field has a value
  • Combining conditions: Use && (AND) or || (OR)
  • Array filtering: "value" in arrayField[]

More Advanced Filtering Example

If you have multiple categories or tags:

function buildAdvancedQuery(filters) {
  let conditions = ['_type == "post"'];
  
  // Filter by multiple selected categories
  if (filters.categories && filters.categories.length > 0) {
    const categoryRefs = filters.categories.map(id => `"${id}"`).join(', ');
    conditions.push(`references(*[_type == "category" && _id in [${categoryRefs}]]._id)`);
  }
  
  // Filter by boolean fields
  if (filters.featured) {
    conditions.push('featured == true');
  }
  
  const filterString = conditions.join(' && ');
  
  return `*[${filterString}] {
    _id,
    title,
    categories[]-> {
      _id,
      title
    }
  }`;
}

Server Components Alternative (Next.js 13+)

If you're using App Router, you can also handle this with URL search params:

// app/posts/page.jsx
import { client } from '@/sanity/lib/client';

export default async function PostsPage({ searchParams }) {
  const params = await searchParams; // await if using Next.js 15
  const featured = params.featured === 'true';
  const published = params.published === 'true';
  
  let conditions = ['_type == "post"'];
  if (featured) conditions.push('featured == true');
  if (published) conditions.push('publishedAt != null');
  
  const query = `*[${conditions.join(' && ')}]`;
  const posts = await client.fetch(query);
  
  return (
    <div>
      <FilterForm /> {/* Client component with checkboxes */}
      <PostsList posts={posts} />
    </div>
  );
}

The beauty of GROQ is that it handles all the filtering server-side, so you only get back the documents that match your criteria. No need to filter arrays in JavaScript after fetching! The pipeline structure means you're querying efficiently at the database level.

Once you have a boolean field in your schema, you can just use
foo == true
or
foo == false
in your Groq query. 🙂
hmm okay so for example if I have a query like
*[_type == "fruit"]{
hmm okay so for example if I have a query like
*[_type == "fruit"]{
  name,
  image
}
How would I dynamically change this with the checkbox? or would each checkbox call a new query?
I’m a little confused. What are you trying to do? 😅
My understanding was that your schema (
fruit
here) has a boolean field that you want to query. But I might have misunderstood you.
in next im using
getServerSideProps
to fetch data from sanity using a groq query of the above, but I want to be able to filter it after the initial fetch to only show specific documents that have been checked off
And it may be that I should be using a different method for fetching but there's other data I need from sanity on the page as well which is part of the getServerSideProps query
Hi Brian! If I’m understanding correctly, you can include the boolean in your projection and then handle the boolean when you map over your fruits. For example, if your
fruit
document has a boolean called
hide
, your query might look like:

*[_type == "fruit"]{
  name,
  image,
  hide
}
Then once
getServerSideProps
returns your array of `fruit`s, you might do:

return fruits.map(fruit => fruit.hide !== true ? fruit : null)

fruits
may need to be destructured, depending on your setup. You’ll also likely want to modify
fruit.hide !== true
to your liking—it’s worth noting that unless you’ve handled indeterminate booleans explicitly in your Studio (with validation or initial values),
true
and
false
aren’t the only options (i.e.,
fruit.hide !== true
would return both
false
and indeterminate values).
Thanks! I'm not sure I really understand how the checkbox filter on the front end will be able to do this, but I guess thats more of a matter of checkbox validation and handling state within next
Maybe I misunderstood. Is the boolean part of your schema, or is it more along the lines that you’ll have a bunch of checkboxes on your front end for different fruits and you want to only return the documents that have that fruit checked?
The later, I could add the boolean to my schema if needed but essentially I'm trying to have checkboxes on the front end that will be able to filter the list of fruit documents based on whether they are checked or not
And apologies for posting this in groq if it's not really a groq question. I wasn't sure if I should be writing a new query or what
Not to worry!
In that case, the boolean won’t go in your schema as I originally suggested as it doesn’t seem to be content but rather a means of filtering. If you’re looking for something like
this CodeSandbox demo , then hopefully that can help (it’s using class components but the fundamentals are similar even if you’re using hooks).
That looks like what I'm looking to do, thanks for the help!
One other quick question how would state in React translate to Next?ex.

state = {
    products: productsList,
    categories: {
      first: false,
      second: false,
      third: false,
      forth: false,
    }
  };
That example I provided uses class components, so it probably wasn’t the best example to send you (though it still works in React as well as Next.js). I’ve updated it to use hooks (i.e.,
useState
) here: https://codesandbox.io/s/react-checkboxes-filter-forked-4oz39s?file=/src/App.js
One change I made was to show nothing if no boxes are checked (the previous example showed everything). You can revert that by changing
null
to
products
on line 49 of App.js.
Thank you!!
When using this with sanity data, can I use the
_id
that sanity gives documents by default for the id that needs to be set to map things in
<li id={id}>
?
^^ this is in reference to inside the ProductList.js
Yes, as long as the
_id
will always be unique in your map, it’s the perfect candidate to use as the key in React.
So to do that I would just change the id to be
_id
like this?
const ProductList = (props) => {
  const { products } = props;

  const renderProducts = products?.map(({ _id, title, category }) => {
    return (
      <li key={_id}>
        <Product title={title} category={category} />
      </li>
    );
  });

  return <ul>{renderProducts}</ul>;
};

export default ProductList;

That looks correct.
hmm on the front end I'm getting a hydration err
Error: Text content does not match server-rendered HTML.
and in the console I'm getting

Warning: Each child in a list should have a unique "key" prop.
Check the top-level render call using <ul>. See <https://reactjs.org/link/warning-keys> for more information.
    at li
    at ProductList (webpack-internal:///./components/ProductList.js:14:13)
Does that error only happen after adding the key?
If I remove the key I get the same error

Sanity – Build the way you think, not the way your CMS thinks

Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.

Was this answer helpful?