# How to Build a Search Filtering and Sorting with Lovable

- Tool: How to Build with Lovable
- Difficulty: Beginner
- Compatibility: Lovable Free or higher
- Last updated: April 2026

## TL;DR

Learn the core patterns for search, filtering, and sorting in Lovable apps. Covers tsvector full-text search in Supabase, composable filter builders, debounced Command search, sortable DataTable with TanStack Table, and filter pills using Badge. These patterns apply to any list view — products, jobs, recipes, or directories.

## Before you start

- Lovable account (Free tier works)
- Supabase project with URL and anon key saved to Cloud tab → Secrets
- Basic understanding of SQL WHERE clauses and PostgreSQL

## Step-by-step guide

### 1. Create the product catalog schema with full-text search vector

Set up a sample products table with a generated tsvector column for full-text search. This schema demonstrates all the filtering patterns in the guide.

```
Build a product catalog with advanced search. Create a Supabase table:

- products: id, name (text), description (text), category (text), brand (text), price (numeric), stock_quantity (int), rating (numeric, 0-5), is_featured (bool default false), tags (text array), created_at

Add a generated search vector column:
ALTER TABLE products ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (
  setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
  setweight(to_tsvector('english', coalesce(description, '')), 'B') ||
  setweight(to_tsvector('english', coalesce(category, '')), 'C') ||
  setweight(to_tsvector('english', coalesce(brand, '')), 'C')
) STORED;

Create an index: CREATE INDEX idx_products_search ON products USING GIN(search_vector);
Create a category index: CREATE INDEX idx_products_category ON products(category);
Create a price index: CREATE INDEX idx_products_price ON products(price);

RLS: allow public SELECT (this is a product catalog, no auth needed for browsing). Seed 20-30 sample products across 5 categories (Electronics, Clothing, Food, Books, Home) so the search and filter patterns have data to work with.
```

> Pro tip: Ask Lovable to seed products using a SQL INSERT with multiple rows in one statement. Seeding in the prompt means the app has real data to demonstrate search and filtering immediately without manual entry.

**Expected result:** The products table is created with the generated search_vector column and GIN index. Running a SQL query SELECT * FROM products WHERE search_vector @@ to_tsquery('english', 'laptop') returns matching products.

### 2. Build the debounced Command search with tsvector

Create the search component using shadcn/ui Command. As the user types, queries Supabase full-text search with a 300ms debounce to avoid a query on every keystroke.

```
// src/components/catalog/ProductSearch.tsx
import { useState, useEffect } from 'react'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Search } from 'lucide-react'
import { supabase } from '@/integrations/supabase/client'

interface Product { id: string; name: string; category: string; price: number }

interface Props {
  onSelect: (query: string) => void
  placeholder?: string
}

export function ProductSearch({ onSelect, placeholder = 'Search products...' }: Props) {
  const [open, setOpen] = useState(false)
  const [input, setInput] = useState('')
  const [results, setResults] = useState<Product[]>([])
  const [isSearching, setIsSearching] = useState(false)

  useEffect(() => {
    if (!input.trim()) { setResults([]); return }
    const timeout = setTimeout(async () => {
      setIsSearching(true)
      const tsQuery = input.trim().split(/\s+/).join(':* & ') + ':*'
      const { data } = await supabase
        .from('products')
        .select('id, name, category, price')
        .textSearch('search_vector', tsQuery, { type: 'websearch', config: 'english' })
        .limit(8)
      setResults(data ?? [])
      setIsSearching(false)
    }, 300)
    return () => clearTimeout(timeout)
  }, [input])

  function handleSelect(value: string) {
    onSelect(value)
    setInput(value)
    setOpen(false)
  }

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button variant="outline" className="w-full justify-start text-muted-foreground">
          <Search className="mr-2 h-4 w-4" />
          {input || placeholder}
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-full p-0" align="start">
        <Command shouldFilter={false}>
          <CommandInput placeholder={placeholder} value={input} onValueChange={setInput} />
          <CommandList>
            {isSearching && <CommandEmpty>Searching...</CommandEmpty>}
            {!isSearching && input && results.length === 0 && <CommandEmpty>No products found.</CommandEmpty>}
            {results.length > 0 && (
              <CommandGroup heading="Products">
                {results.map((p) => (
                  <CommandItem key={p.id} value={p.name} onSelect={handleSelect}>
                    <div className="flex justify-between w-full">
                      <span>{p.name}</span>
                      <span className="text-muted-foreground text-sm">{p.category} · ${p.price}</span>
                    </div>
                  </CommandItem>
                ))}
              </CommandGroup>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}
```

**Expected result:** The Command search component shows suggestions as the user types. Results appear within 300ms. Selecting a suggestion updates the main product list to show matching results.

### 3. Build the composable filter sidebar with URL state

Create a filter sidebar with multiple filter controls (category, price range, rating, in-stock). All filter state lives in URL search params so filters persist on refresh.

```
Build the filter system:

1. URL state hook at src/hooks/useProductFilters.ts:
   - Use useSearchParams from react-router-dom
   - Define filter state: { q: string, category: string, minPrice: string, maxPrice: string, minRating: string, inStockOnly: string, sortBy: string, sortDir: 'asc'|'desc', page: string }
   - Expose a setFilter(key, value) function that updates the URL search params
   - Expose a clearFilter(key) function and a clearAll() function
   - Expose an activeFilters array (non-empty, non-default filter entries) for the filter pills

2. Supabase query builder at src/utils/buildProductQuery.ts:
   - Accept a filter state object
   - Start with: let query = supabase.from('products').select('*', { count: 'exact' })
   - If q: query = query.textSearch('search_vector', q, { type: 'websearch', config: 'english' })
   - If category: query = query.eq('category', category)
   - If minPrice: query = query.gte('price', minPrice)
   - If maxPrice: query = query.lte('price', maxPrice)
   - If minRating: query = query.gte('rating', minRating)
   - If inStockOnly: query = query.gt('stock_quantity', 0)
   - Sort: query = query.order(sortBy || 'name', { ascending: sortDir !== 'desc' })
   - Pagination: query = query.range(pageFrom, pageTo)
   - Return the configured query

3. Filter sidebar component with: category checkboxes (show distinct categories from DB), price range dual Input, rating Select, in-stock Switch, 'Clear All' Button
```

**Expected result:** Selecting a category filter updates the URL and immediately filters the product list. Refreshing the page preserves the filter state. The URL shows ?category=Electronics&minPrice=100.

### 4. Add filter pills and sortable DataTable

Build the active filter pills that show current filters with remove buttons, and the sortable DataTable using TanStack Table with click-to-sort column headers.

```
Build two components:

1. Filter pills at src/components/catalog/FilterPills.tsx:
   - Read activeFilters from the useProductFilters hook
   - Render each active filter as a Badge with an X button: e.g. 'Category: Electronics ×'
   - Format filter display labels: { q: 'Search', category: 'Category', minPrice: 'Min Price', maxPrice: 'Max Price', minRating: 'Min Rating', inStockOnly: 'In Stock Only' }
   - Clicking X calls clearFilter(key) which removes that param from the URL
   - If activeFilters.length > 0, show a 'Clear all' text button at the end of the pill list
   - Animate pill enter/exit using a simple CSS transition

2. Sortable product DataTable at src/components/catalog/ProductTable.tsx:
   - Use TanStack Table v8 (ask Lovable to implement from scratch since it's included in shadcn/ui DataTable pattern)
   - Columns: Name (sortable), Category (sortable), Brand, Price (sortable, formatted as currency), Rating (sortable, rendered as stars or number), Stock (sortable, shown as In Stock/Out of Stock Badge), Actions
   - Column headers that are sortable show an up/down arrow icon. Clicking sorts ascending, clicking again sorts descending, clicking a third time removes the sort
   - Sorting is server-side: column header click updates sortBy and sortDir in URL params, which triggers a new Supabase query
   - Pagination footer: show 'Showing X-Y of Z results', page size Select (10, 25, 50), previous/next Buttons
```

**Expected result:** Active filters show as Badge pills below the search bar. Clicking X on a pill removes that filter. Clicking a column header sorts the table and shows the sort direction arrow.

## Complete code example

File: `src/utils/buildProductQuery.ts`

```typescript
import { supabase } from '@/integrations/supabase/client'

export interface ProductFilters {
  q?: string
  category?: string
  minPrice?: string
  maxPrice?: string
  minRating?: string
  inStockOnly?: string
  sortBy?: string
  sortDir?: string
  page?: string
  pageSize?: number
}

export function buildProductQuery(filters: ProductFilters) {
  const pageSize = filters.pageSize ?? 25
  const page = parseInt(filters.page ?? '1', 10)
  const from = (page - 1) * pageSize
  const to = from + pageSize - 1

  let query = supabase.from('products').select(
    'id, name, description, category, brand, price, stock_quantity, rating, is_featured, tags, created_at',
    { count: 'exact' }
  )

  if (filters.q?.trim()) {
    const tsQuery = filters.q.trim().split(/\s+/).filter(Boolean).join(' & ')
    query = query.textSearch('search_vector', tsQuery, { type: 'websearch', config: 'english' })
  }

  if (filters.category) query = query.eq('category', filters.category)
  if (filters.minPrice) query = query.gte('price', parseFloat(filters.minPrice))
  if (filters.maxPrice) query = query.lte('price', parseFloat(filters.maxPrice))
  if (filters.minRating) query = query.gte('rating', parseFloat(filters.minRating))
  if (filters.inStockOnly === 'true') query = query.gt('stock_quantity', 0)

  const sortColumn = filters.sortBy ?? 'name'
  const ascending = filters.sortDir !== 'desc'
  query = query.order(sortColumn, { ascending })

  query = query.range(from, to)

  return { query, page, pageSize }
}
```

## Common mistakes

- **Using ILIKE for search instead of tsvector** — ILIKE '%search term%' cannot use an index, causing full table scans. It also cannot rank results by relevance or handle stemming (searching 'running' should match 'run'). Performance degrades severely with more than 10,000 rows. Fix: Use PostgreSQL's full-text search with tsvector and to_tsquery. The GIN index on the search_vector column makes queries fast regardless of table size. Supabase's .textSearch() client method maps directly to this. Add the generated column and index as shown in Step 1.
- **Building filter queries with string concatenation** — Concatenating SQL WHERE clauses as strings opens SQL injection vulnerabilities if any filter values come from user input. It also makes the query hard to read and maintain. Fix: Use the Supabase client's chainable query builder as shown in the buildProductQuery utility. Each .eq(), .gte(), .lte() call is safely parameterized. The supabase-js client handles all parameter escaping automatically.
- **Storing filter state in React useState instead of URL params** — useState resets on page navigation or refresh. Users who apply filters, open a product detail, and press back lose all their filters. This is a common UX complaint in list-based apps. Fix: Use useSearchParams from React Router to store all filter state in the URL. The useProductFilters hook in this guide wraps useSearchParams and provides a clean interface. Filters persist across navigation, refreshes, and browser back/forward.
- **Running sorts and pagination in the browser on the full dataset** — If you fetch all 10,000 products from Supabase and sort/paginate in JavaScript, you're transferring all that data over the network on every page load. This is slow and costly. Fix: Always sort and paginate server-side. Add .order(column, { ascending }) and .range(from, to) to your Supabase query. Only fetch the current page's data. For sort changes, re-run the query with the new sort parameters.
- **Not debouncing the search input** — Without debounce, typing 'laptop' triggers 6 Supabase queries (one per character). This creates network congestion and can hit Supabase's connection limits. Fix: Debounce the search with a 300ms delay using setTimeout/clearTimeout as shown in the ProductSearch component. For URL-based search state, use a separate local state for the input value and only sync to the URL after the debounce completes.

## Best practices

- Use server-side filtering, sorting, and pagination for all list views. Never fetch all rows and filter in JavaScript — this approach does not scale past a few hundred rows.
- Store all filter state in URL search params, not React state. This makes filters shareable, bookmarkable, and persistent across page refreshes. It also makes the app state debuggable — just look at the URL.
- Add a GIN index on your tsvector column before deploying to production. Without the index, full-text search is fast for small datasets but degrades to a full table scan for large ones. The index makes text search fast at any table size.
- Show an empty state that includes filter context: 'No products found matching 'laptop' in Electronics under $500. Try removing a filter.' This helps users understand why they see no results and how to fix it.
- Use the websearch type in Supabase's textSearch rather than the plain type. Websearch allows natural language queries ('red laptop under 500') and handles common operators automatically, matching user expectations from Google-style search.
- Test your filter combinations for edge cases: all filters active simultaneously, min price greater than max price, search query with special characters. Add Zod validation to the filter params to prevent invalid state from reaching the database query.

## Frequently asked questions

### What is a tsvector and why is it better than LIKE search?

A tsvector is PostgreSQL's native full-text search data type. It stores normalized tokens (words stripped of common suffixes, stop words removed) extracted from your text columns. Searching a tsvector uses a GIN index, making it O(log n) regardless of table size. ILIKE '%term%' requires a sequential scan of every row — it gets slower as your table grows. Tsvector also supports relevance ranking, stemming (matching 'running' to 'run'), and multi-language configurations.

### Can I search across multiple tables simultaneously?

Yes. Create a view or materialized view that JOINs the tables and adds a combined tsvector column. Run your full-text search on the view instead of individual tables. Alternatively, use a UNION query in a Supabase RPC function that searches each table separately and returns a unified result set with a source column indicating which table each result came from.

### How do I handle filters that depend on each other (e.g. city depends on selected country)?

Use cascading queries. When the country filter changes, query distinct cities for that country and update the city filter options. The building block is a Supabase query like: supabase.from('products').select('city').eq('country', selectedCountry). Reset the dependent filter value when its parent changes using clearFilter('city') in the setFilter function.

### Why should I sync filter state to the URL instead of a state management library?

URL-based state is automatically shareable, bookmarkable, and survives page refreshes without any extra code. If a user finds a filtered view they want to return to, they can bookmark the URL. Customer support can ask users to share the URL to reproduce a search issue. Redux or Zustand state would require separate persistence logic to achieve the same result. React Router's useSearchParams is the built-in solution.

### What is the performance limit for the search patterns in this guide?

With the GIN index on tsvector and proper indexes on filter columns (category, price), this pattern scales comfortably to millions of rows. PostgreSQL's query planner combines indexes efficiently for multi-filter queries. The practical limit is usually the number of concurrent database connections, not table size. Supabase's connection pooler (PgBouncer) handles this automatically. At extremely high traffic (thousands of concurrent searches), add a search-dedicated caching layer or switch to Elasticsearch.

### How do I make the search work for non-English text?

Replace 'english' in the tsvector configuration with the appropriate language: 'french', 'german', 'spanish', 'russian', etc. For mixed-language content or languages without PostgreSQL dictionaries (Japanese, Chinese, Arabic), use the 'simple' configuration which tokenizes by whitespace without stemming. The GIN index and @@ operator still work, just without language-specific optimizations.

### Can I add autocomplete suggestions from a separate suggestions table?

Yes. Add a search_suggestions table pre-populated with common search terms, product names, and category names. Query this table with ILIKE for fast prefix matching (it's a small table, so ILIKE is fine). Show suggestions in the Command dropdown alongside real-time product results. Update the suggestions table periodically with popular search terms from your analytics.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/search-filtering-and-sorting
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/search-filtering-and-sorting
