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.
What you're building
This guide teaches reusable patterns rather than one specific app. You'll build a sample product catalog that demonstrates every search and filtering technique. The patterns then apply directly to any list-based feature: job boards, recipe apps, marketplaces, directories.
Full-text search uses PostgreSQL's built-in tsvector type. You add a generated column search_vector that combines the fields you want to search (name, description, category). PostgreSQL automatically updates this column on insert/update. Searching uses the .textSearch() method in Supabase's client library, which maps to the @@ tsvector operator. This is faster and more capable than ILIKE for real-world search.
Filter state is stored in a URL search params object (useSearchParams from React Router). Every filter change updates the URL, which means filters persist on page refresh, can be bookmarked, and can be shared as links. The filter state is the single source of truth — components read from the URL, not local state. This pattern eliminates the 'my filters disappeared after refresh' problem.
Final result
A product catalog with full-text search, multi-filter sidebar, active filter pills, sortable DataTable, and URL-synced state — reusable patterns for any Lovable app.
Tech stack
Prerequisites
- 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
Build steps
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.
1Build a product catalog with advanced search. Create a Supabase table:23- 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_at45Add a generated search vector column:6ALTER TABLE products ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (7 setweight(to_tsvector('english', coalesce(name, '')), 'A') ||8 setweight(to_tsvector('english', coalesce(description, '')), 'B') ||9 setweight(to_tsvector('english', coalesce(category, '')), 'C') ||10 setweight(to_tsvector('english', coalesce(brand, '')), 'C')11) STORED;1213Create an index: CREATE INDEX idx_products_search ON products USING GIN(search_vector);14Create a category index: CREATE INDEX idx_products_category ON products(category);15Create a price index: CREATE INDEX idx_products_price ON products(price);1617RLS: 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.
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.
1// src/components/catalog/ProductSearch.tsx2import { useState, useEffect } from 'react'3import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'4import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'5import { Button } from '@/components/ui/button'6import { Search } from 'lucide-react'7import { supabase } from '@/integrations/supabase/client'89interface Product { id: string; name: string; category: string; price: number }1011interface Props {12 onSelect: (query: string) => void13 placeholder?: string14}1516export function ProductSearch({ onSelect, placeholder = 'Search products...' }: Props) {17 const [open, setOpen] = useState(false)18 const [input, setInput] = useState('')19 const [results, setResults] = useState<Product[]>([])20 const [isSearching, setIsSearching] = useState(false)2122 useEffect(() => {23 if (!input.trim()) { setResults([]); return }24 const timeout = setTimeout(async () => {25 setIsSearching(true)26 const tsQuery = input.trim().split(/\s+/).join(':* & ') + ':*'27 const { data } = await supabase28 .from('products')29 .select('id, name, category, price')30 .textSearch('search_vector', tsQuery, { type: 'websearch', config: 'english' })31 .limit(8)32 setResults(data ?? [])33 setIsSearching(false)34 }, 300)35 return () => clearTimeout(timeout)36 }, [input])3738 function handleSelect(value: string) {39 onSelect(value)40 setInput(value)41 setOpen(false)42 }4344 return (45 <Popover open={open} onOpenChange={setOpen}>46 <PopoverTrigger asChild>47 <Button variant="outline" className="w-full justify-start text-muted-foreground">48 <Search className="mr-2 h-4 w-4" />49 {input || placeholder}50 </Button>51 </PopoverTrigger>52 <PopoverContent className="w-full p-0" align="start">53 <Command shouldFilter={false}>54 <CommandInput placeholder={placeholder} value={input} onValueChange={setInput} />55 <CommandList>56 {isSearching && <CommandEmpty>Searching...</CommandEmpty>}57 {!isSearching && input && results.length === 0 && <CommandEmpty>No products found.</CommandEmpty>}58 {results.length > 0 && (59 <CommandGroup heading="Products">60 {results.map((p) => (61 <CommandItem key={p.id} value={p.name} onSelect={handleSelect}>62 <div className="flex justify-between w-full">63 <span>{p.name}</span>64 <span className="text-muted-foreground text-sm">{p.category} · ${p.price}</span>65 </div>66 </CommandItem>67 ))}68 </CommandGroup>69 )}70 </CommandList>71 </Command>72 </PopoverContent>73 </Popover>74 )75}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.
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.
1Build the filter system:231. URL state hook at src/hooks/useProductFilters.ts:4 - Use useSearchParams from react-router-dom5 - Define filter state: { q: string, category: string, minPrice: string, maxPrice: string, minRating: string, inStockOnly: string, sortBy: string, sortDir: 'asc'|'desc', page: string }6 - Expose a setFilter(key, value) function that updates the URL search params7 - Expose a clearFilter(key) function and a clearAll() function8 - Expose an activeFilters array (non-empty, non-default filter entries) for the filter pills9102. Supabase query builder at src/utils/buildProductQuery.ts:11 - Accept a filter state object12 - Start with: let query = supabase.from('products').select('*', { count: 'exact' })13 - If q: query = query.textSearch('search_vector', q, { type: 'websearch', config: 'english' })14 - If category: query = query.eq('category', category)15 - If minPrice: query = query.gte('price', minPrice)16 - If maxPrice: query = query.lte('price', maxPrice)17 - If minRating: query = query.gte('rating', minRating)18 - If inStockOnly: query = query.gt('stock_quantity', 0)19 - Sort: query = query.order(sortBy || 'name', { ascending: sortDir !== 'desc' })20 - Pagination: query = query.range(pageFrom, pageTo)21 - Return the configured query22233. Filter sidebar component with: category checkboxes (show distinct categories from DB), price range dual Input, rating Select, in-stock Switch, 'Clear All' ButtonExpected 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.
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.
1Build two components:231. Filter pills at src/components/catalog/FilterPills.tsx:4 - Read activeFilters from the useProductFilters hook5 - Render each active filter as a Badge with an X button: e.g. 'Category: Electronics ×'6 - Format filter display labels: { q: 'Search', category: 'Category', minPrice: 'Min Price', maxPrice: 'Max Price', minRating: 'Min Rating', inStockOnly: 'In Stock Only' }7 - Clicking X calls clearFilter(key) which removes that param from the URL8 - If activeFilters.length > 0, show a 'Clear all' text button at the end of the pill list9 - Animate pill enter/exit using a simple CSS transition10112. Sortable product DataTable at src/components/catalog/ProductTable.tsx:12 - Use TanStack Table v8 (ask Lovable to implement from scratch since it's included in shadcn/ui DataTable pattern)13 - 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), Actions14 - Column headers that are sortable show an up/down arrow icon. Clicking sorts ascending, clicking again sorts descending, clicking a third time removes the sort15 - Sorting is server-side: column header click updates sortBy and sortDir in URL params, which triggers a new Supabase query16 - Pagination footer: show 'Showing X-Y of Z results', page size Select (10, 25, 50), previous/next ButtonsExpected 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
1import { supabase } from '@/integrations/supabase/client'23export interface ProductFilters {4 q?: string5 category?: string6 minPrice?: string7 maxPrice?: string8 minRating?: string9 inStockOnly?: string10 sortBy?: string11 sortDir?: string12 page?: string13 pageSize?: number14}1516export function buildProductQuery(filters: ProductFilters) {17 const pageSize = filters.pageSize ?? 2518 const page = parseInt(filters.page ?? '1', 10)19 const from = (page - 1) * pageSize20 const to = from + pageSize - 12122 let query = supabase.from('products').select(23 'id, name, description, category, brand, price, stock_quantity, rating, is_featured, tags, created_at',24 { count: 'exact' }25 )2627 if (filters.q?.trim()) {28 const tsQuery = filters.q.trim().split(/\s+/).filter(Boolean).join(' & ')29 query = query.textSearch('search_vector', tsQuery, { type: 'websearch', config: 'english' })30 }3132 if (filters.category) query = query.eq('category', filters.category)33 if (filters.minPrice) query = query.gte('price', parseFloat(filters.minPrice))34 if (filters.maxPrice) query = query.lte('price', parseFloat(filters.maxPrice))35 if (filters.minRating) query = query.gte('rating', parseFloat(filters.minRating))36 if (filters.inStockOnly === 'true') query = query.gt('stock_quantity', 0)3738 const sortColumn = filters.sortBy ?? 'name'39 const ascending = filters.sortDir !== 'desc'40 query = query.order(sortColumn, { ascending })4142 query = query.range(from, to)4344 return { query, page, pageSize }45}Customization ideas
Saved search and filter presets
Add a saved_searches table (user_id, name, filter_params JSONB) for logged-in users. A 'Save This Search' Button stores the current URL params as a JSON object. Show saved searches as quick-access Buttons above the filter sidebar. Clicking one applies all stored filters instantly.
Faceted filter counts
Show how many results match each filter option, like e-commerce sites do ('Electronics (24)', 'Clothing (18)'). Use a Supabase RPC function that runs the current query but groups by category and returns counts. Update the counts as other filters change so users can see the impact before clicking.
Range slider for price filter
Replace the min/max price inputs with a dual-handle range slider. Use the Radix UI Slider component (included in shadcn/ui) configured with two values. Fetch the min and max prices from the database to set the slider bounds. Update the URL params on slider release to avoid a query on every pixel of drag.
Infinite scroll instead of pagination
Replace the page-based pagination with infinite scroll. Use the Intersection Observer API to detect when the user reaches the bottom of the list. Fetch the next page and append results to the existing list. Store the cursor (the last item's ID or created_at) in state rather than a page number for stable pagination.
Common pitfalls
Pitfall: Using ILIKE for search instead of tsvector
How to avoid: 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.
Pitfall: Building filter queries with string concatenation
How to avoid: 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.
Pitfall: Storing filter state in React useState instead of URL params
How to avoid: 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.
Pitfall: Running sorts and pagination in the browser on the full dataset
How to avoid: 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.
Pitfall: Not debouncing the search input
How to avoid: 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.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a search and filter system in TypeScript with Supabase. I have a buildProductQuery function that chains Supabase query builder methods based on a filter state object. Help me extend it to support a 'tags' filter: the products table has a text array column 'tags'. The filter should accept multiple selected tags and return products that have ALL of the selected tags (AND logic). Show how to use Supabase's .contains() method for array filtering.
Add a 'Sort by relevance' option to the product search. When the user has typed a search query (q param is set), add a 'Relevance' option at the top of the Sort By Select. Selecting it should sort by ts_rank(search_vector, to_tsquery('english', query)) descending. Use a Supabase RPC function get_products_by_relevance(query text, ...) that accepts the same filter params and includes ts_rank in the ORDER BY. Show the relevance score as a small gray number on each result card.
In Supabase, create a function get_filter_facets(p_category text, p_q text) that returns the counts for all filterable dimensions simultaneously. Return a JSONB object: { categories: [{name, count}], price_ranges: [{label, min, max, count}], ratings: [{min_rating, count}], in_stock_count: int, total_count: int }. This lets the frontend show accurate facet counts without making separate queries for each filter dimension. Use window functions or subqueries to calculate all counts in one database round trip.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation