# How to Query with Filters in Supabase

- Tool: Supabase
- Difficulty: Beginner
- Time required: 10-15 min
- Compatibility: Supabase (all plans), @supabase/supabase-js v2+
- Last updated: March 2026

## TL;DR

The Supabase JavaScript client provides chainable filter methods for building precise queries without writing raw SQL. Use .eq() for exact matches, .gt()/.lt() for ranges, .like()/.ilike() for pattern matching, .in() for multiple values, and .contains() for arrays and JSON. Filters map directly to PostgREST query parameters, which translate to PostgreSQL WHERE clauses. Always combine filters with RLS policies to ensure users only access authorized data.

## Building Filtered Queries with the Supabase JavaScript Client

Supabase's JavaScript client provides a fluent API for filtering data that maps to PostgREST query parameters under the hood. Each filter method translates to a PostgreSQL WHERE clause, giving you the full power of PostgreSQL without writing raw SQL. This tutorial covers every common filter pattern — from simple equality checks to complex multi-condition queries with OR logic and nested relationships.

## Before you start

- A Supabase project with tables containing data
- The Supabase JS client installed and initialized
- RLS policies configured for your tables
- Basic understanding of SQL WHERE clauses

## Step-by-step guide

### 1. Use equality and inequality filters

The most common filters are .eq() for exact matches and .neq() for exclusion. These translate to WHERE column = value and WHERE column != value in SQL. You can chain multiple .eq() calls to filter on several columns simultaneously — they combine with AND logic by default.

```
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

// Exact match
const { data: activeUsers } = await supabase
  .from('users')
  .select('id, name, email')
  .eq('status', 'active');

// Not equal
const { data: nonAdmins } = await supabase
  .from('users')
  .select('id, name, role')
  .neq('role', 'admin');

// Multiple equality filters (AND logic)
const { data: activeAdmins } = await supabase
  .from('users')
  .select('id, name')
  .eq('status', 'active')
  .eq('role', 'admin');
```

**Expected result:** Queries return only rows that match all specified equality conditions.

### 2. Use range filters for numbers and dates

Range filters let you query rows where a column value falls within a range. Use .gt() for greater than, .gte() for greater than or equal, .lt() for less than, and .lte() for less than or equal. These are especially useful for date ranges, price filters, and pagination by timestamp.

```
// Numbers: products under $50
const { data: affordable } = await supabase
  .from('products')
  .select('name, price')
  .lt('price', 50);

// Numbers: orders between $10 and $100
const { data: midRange } = await supabase
  .from('orders')
  .select('id, total')
  .gte('total', 10)
  .lte('total', 100);

// Dates: posts from the last 7 days
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const { data: recentPosts } = await supabase
  .from('posts')
  .select('id, title, created_at')
  .gte('created_at', weekAgo)
  .order('created_at', { ascending: false });
```

**Expected result:** Queries return only rows where the specified columns fall within the given range.

### 3. Use pattern matching with like and ilike

Use .like() for case-sensitive pattern matching and .ilike() for case-insensitive matching. The % wildcard matches any sequence of characters, and _ matches a single character. These translate to SQL LIKE and ILIKE operators. For more complex text search, consider using .textSearch() with full-text search indexes.

```
// Case-insensitive search for names containing 'john'
const { data: johns } = await supabase
  .from('users')
  .select('id, name, email')
  .ilike('name', '%john%');

// Case-sensitive: emails ending with @gmail.com
const { data: gmailUsers } = await supabase
  .from('users')
  .select('id, email')
  .like('email', '%@gmail.com');

// Names starting with 'A'
const { data: aNames } = await supabase
  .from('users')
  .select('id, name')
  .ilike('name', 'A%');

// Full-text search (requires tsvector column or function)
const { data: searchResults } = await supabase
  .from('articles')
  .select('id, title, body')
  .textSearch('title', 'supabase authentication');
```

**Expected result:** Queries return rows where the text column matches the specified pattern, with or without case sensitivity.

### 4. Filter by multiple values with in and contains

Use .in() to match against a list of values (SQL IN operator). Use .contains() for array columns and JSONB containment queries. Use .containedBy() to check if an array column is a subset of the provided values. These are powerful filters for tagging systems, multi-select filters, and JSON data.

```
// Match any of multiple values (SQL IN)
const { data: selectedOrders } = await supabase
  .from('orders')
  .select('*')
  .in('status', ['pending', 'processing', 'shipped']);

// Array column: posts that have the 'supabase' tag
const { data: taggedPosts } = await supabase
  .from('posts')
  .select('id, title, tags')
  .contains('tags', ['supabase']);

// JSONB containment: products with specific metadata
const { data: electronics } = await supabase
  .from('products')
  .select('id, name, metadata')
  .contains('metadata', { category: 'electronics' });

// Array is subset of values
const { data: basicPosts } = await supabase
  .from('posts')
  .select('id, title, tags')
  .containedBy('tags', ['supabase', 'postgres', 'react']);
```

**Expected result:** Queries return rows matching any value in the list, or rows where array/JSONB columns contain the specified values.

### 5. Combine filters with OR logic

By default, chained filters use AND logic. To use OR logic, pass a comma-separated string of conditions to the .or() method. Each condition uses the format column.operator.value. You can nest OR within AND by combining .or() with other filter methods.

```
// OR: status is 'active' OR role is 'admin'
const { data: activeOrAdmin } = await supabase
  .from('users')
  .select('id, name, status, role')
  .or('status.eq.active,role.eq.admin');

// Combine AND and OR: active users who are admin OR moderator
const { data: activeStaff } = await supabase
  .from('users')
  .select('id, name, status, role')
  .eq('status', 'active')
  .or('role.eq.admin,role.eq.moderator');

// OR with range: price < 10 OR price > 100
const { data: extremePrices } = await supabase
  .from('products')
  .select('id, name, price')
  .or('price.lt.10,price.gt.100');
```

**Expected result:** Queries return rows matching any of the OR conditions, optionally combined with other AND filters.

### 6. Filter on related tables through foreign key joins

When you use the nested select syntax to fetch related data, you can also filter on the related table's columns. Use the foreignTable option in filter methods to specify which related table to filter on. This translates to a filtered join in PostgREST.

```
// Fetch orders with their items, only orders with quantity > 5
const { data: largeOrders } = await supabase
  .from('orders')
  .select('id, total, order_items(product_name, quantity)')
  .gt('order_items.quantity', 5);

// Fetch users with their posts, filter posts by published status
const { data: usersWithPosts } = await supabase
  .from('users')
  .select('id, name, posts(id, title, status)')
  .eq('posts.status', 'published');

// Combine filters on both parent and child tables
const { data: activeWithRecent } = await supabase
  .from('users')
  .select('id, name, posts(id, title, created_at)')
  .eq('status', 'active')
  .gte('posts.created_at', weekAgo);
```

**Expected result:** Related data is filtered within the nested response. Parent rows still appear even if no child rows match the filter.

## Complete code example

File: `supabase-query-filters.ts`

```typescript
// Complete Supabase filter examples
// Demonstrates all common filter patterns

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

// Type-safe query builder examples
interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
  tags: string[];
  metadata: Record<string, unknown>;
  created_at: string;
}

// Equality filter
async function getByCategory(category: string) {
  return supabase.from('products').select('*').eq('category', category);
}

// Range filter
async function getPriceRange(min: number, max: number) {
  return supabase.from('products').select('*').gte('price', min).lte('price', max);
}

// Pattern matching
async function searchProducts(term: string) {
  return supabase.from('products').select('*').ilike('name', `%${term}%`);
}

// Multiple values
async function getByCategories(categories: string[]) {
  return supabase.from('products').select('*').in('category', categories);
}

// Array containment
async function getByTag(tag: string) {
  return supabase.from('products').select('*').contains('tags', [tag]);
}

// OR conditions
async function getCheapOrExpensive() {
  return supabase.from('products').select('*').or('price.lt.10,price.gt.1000');
}

// Null checking
async function getWithoutCategory() {
  return supabase.from('products').select('*').is('category', null);
}

// Not null
async function getWithCategory() {
  return supabase.from('products').select('*').not('category', 'is', null);
}

// Combined query with ordering and pagination
async function getFilteredProducts(
  category: string,
  maxPrice: number,
  page: number,
  pageSize: number
) {
  const start = page * pageSize;
  const end = start + pageSize - 1;

  return supabase
    .from('products')
    .select('id, name, price, category', { count: 'exact' })
    .eq('category', category)
    .lte('price', maxPrice)
    .order('price', { ascending: true })
    .range(start, end);
}

export {
  getByCategory,
  getPriceRange,
  searchProducts,
  getByCategories,
  getByTag,
  getCheapOrExpensive,
  getWithoutCategory,
  getWithCategory,
  getFilteredProducts,
};
```

## Common mistakes

- **Using .like() with a leading wildcard ('%term') on a non-indexed column, causing full table scans on large tables** — undefined Fix: For search functionality, use full-text search with a GIN index instead of .ilike('%term%'). Or use a trigram index: CREATE EXTENSION pg_trgm; CREATE INDEX idx_name_trgm ON users USING gin (name gin_trgm_ops);
- **Assuming .or() takes individual method calls instead of a comma-separated string** — undefined Fix: The .or() method takes a single string with PostgREST syntax: .or('status.eq.active,role.eq.admin'). It does not support chaining method calls.
- **Filtering on a related table and expecting parent rows to be excluded when no child rows match** — undefined Fix: Filtering on a related table only filters the nested data, not the parent rows. To exclude parents without matching children, use an RPC function or a database view.
- **Forgetting to add RLS SELECT policies when filters return empty results on a table with data** — undefined Fix: Check that RLS policies exist for the table and role you are querying with. Empty results with no error usually means RLS is blocking access.

## Best practices

- Use specific column selection (.select('id, name')) instead of .select('*') to reduce payload size
- Add database indexes on columns that appear frequently in filters, especially for large tables
- Combine filters with .order() and .range() for efficient paginated queries
- Use .ilike() instead of .like() for user-facing search to handle case insensitivity
- Use .in() instead of multiple .or('column.eq.value') when matching against a list of values
- Always handle the error property from query results to catch RLS or permission issues
- Use { count: 'exact' } in .select() when you need total row counts for pagination UI
- For complex filtering logic, consider creating a database function and calling it with .rpc()

## Frequently asked questions

### Can I combine AND and OR filters in the same query?

Yes. Chained filter methods (eq, gt, etc.) use AND logic by default. Use the .or() method for OR conditions. You can combine both: .eq('status', 'active').or('role.eq.admin,role.eq.moderator') means status is active AND (role is admin OR moderator).

### How do I filter for NULL values?

Use .is('column', null) to find rows where a column is NULL, and .not('column', 'is', null) to find rows where a column is NOT NULL. Do not use .eq('column', null) — it will not work.

### Why does my filter return an empty array instead of an error?

This usually means RLS is blocking access. When RLS is enabled with no matching SELECT policy, queries return empty results silently. Check your RLS policies in the Dashboard.

### Can I filter on JSONB nested fields?

Yes. Use the arrow syntax in the column name: .eq('metadata->category', 'electronics') for exact matches on nested JSON fields. For containment queries, use .contains('metadata', { category: 'electronics' }).

### Is there a limit to how many filters I can chain?

There is no hard limit on the number of filters, but very complex queries with many filters may hit URL length limits (since filters are passed as query parameters). For extremely complex queries, consider using an RPC function.

### How do I filter on a column in a related table?

Use the dot notation: .eq('related_table.column', 'value'). This filters which related rows are included in the nested response, but does not exclude parent rows that have no matching children.

### Can RapidDev help build complex filtered queries for my Supabase application?

Yes. RapidDev can design optimized query patterns for your Supabase application including complex filtering, full-text search, pagination, and performance tuning with proper indexes.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-query-with-filters-in-supabase
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-query-with-filters-in-supabase
