Feature spec
IntermediateCategory
personalization-ux
Build with AI
3–6 hours with Lovable or V0
Custom build
1–2 weeks custom dev
Running cost
$0–$100/mo depending on scale and search provider
Works on
Everything it takes to ship Search — parts, prompts, and real costs.
Search needs a full-text engine with a GIN index on a tsvector column (not ILIKE — that causes full table scans), a debounced input component with a Cmd+K keyboard shortcut, result highlighting, and an empty state with recent searches. With V0 or Lovable you can ship working search in 3–6 hours. Costs range from $0 (Supabase built-in FTS) to $25–$100/month if Algolia is added for advanced ranking at 10K+ users.
What Search Actually Requires to Work Well
Search looks deceptively simple — query the database, return matching rows. The problems emerge at scale and in edge cases. Querying on every keystroke floods the database with partial-word searches. Using ILIKE ('%query%') works for 1K rows but causes full table scans on 100K rows, returning results in 2–5 seconds. The word 'recipes' doesn't find 'recipe' because ILIKE is an exact match. And there is no good place to surface recent searches if the search opens in a different UI pattern each time it is accessed. Production search solves these problems with a debounced input, a tsvector column with a GIN index for full-text search including stemming, a Cmd+K keyboard-accessible modal pattern, and result term highlighting. Algolia or Typesense are drop-in upgrades for apps that need typo tolerance or cross-collection search.
What users consider table stakes in 2026
- Sub-200ms response with debounced input — no query fires until 300ms after the user stops typing
- Highlighted matching terms in result snippets so users can see exactly why a result was returned
- Empty state with suggested searches or recent queries shown before any input is entered
- Keyboard navigation through results: arrow up/down to highlight, Enter to select, Escape to close the modal
- Mobile-friendly full-screen search modal with large touch targets and a prominent close button
- Graceful no-results message with alternative suggestions, not a blank screen
Anatomy of the Feature
Seven components build the complete search experience. AI tools generate the input and result list reliably. The GIN index, tsvector column, and mobile focus behavior are the three pieces that require explicit specification to avoid the most expensive failure modes.
Search Input
UIControlled input with useDebounce(query, 300) from usehooks-ts. Debounce prevents a database query on every keystroke — only sends a request 300ms after the user pauses. A Cmd/Ctrl+K global keyboard shortcut opens the search in a Radix UI Dialog or shadcn/ui CommandDialog (cmdk library). The input shows a clear button and a loading spinner during the fetch. Queries under 3 characters skip the search and show recent searches instead.
Note: The cmdk library's CommandDialog component is the most-used shadcn/ui pattern for search palettes. V0 generates this natively. Lovable requires an explicit request for the cmdk pattern.
Full-Text Search Engine
BackendSupabase full-text search via PostgreSQL tsvector columns and to_tsquery() or websearch_to_tsquery(). A GENERATED ALWAYS column computes the tsvector automatically from the searchable fields. A GIN index on the tsvector column makes queries fast at any scale. The websearch_to_tsquery() function parses natural language input with quotes and minus signs, unlike to_tsquery() which requires exact syntax. Alternatively, Algolia InstantSearch provides advanced ranking, typo tolerance, and geo-search at additional cost.
Note: Use websearch_to_tsquery() for the user-facing search, not to_tsquery(). A user typing 'recipe chicken' would need to write 'recipe & chicken' for to_tsquery() to parse it — websearch_to_tsquery handles the natural language form automatically.
Result Renderer
UIA list component that renders each result with highlighted matched terms. Highlighting wraps matched text by splitting the result string on the query term and rendering alternating spans: unmatched text as plain text, matched text in a highlighted span. For large result sets (50+ items), @tanstack/react-virtual virtualizes the list to avoid rendering hundreds of DOM nodes. Flutter equivalent: ListView.builder with a flutter_highlight package for term emphasis.
Note: Do not use innerHTML or dangerouslySetInnerHTML for highlighting — split the string into an array of {text, isMatch} segments and map them to React elements. This avoids XSS from user-controlled search terms appearing in the markup.
Filters Panel
UIFaceted filters for category, date range, and status applied as additional SQL WHERE clauses alongside the tsvector search. On web: Radix UI Select and Popover for the filter controls. Filter state serialized to URL search params via the nuqs library so filtered search results have shareable URLs and survive page refresh.
Note: Serialize filter state to the URL rather than React state alone — this gives users shareable search URLs and makes the browser back button work correctly within the search experience.
Search Index
DataA GENERATED ALWAYS tsvector column on the content table, computed from the concatenation of all searchable text fields. A GIN index on this column. Both are created once in the Supabase SQL Editor. The tsvector with English dictionary configuration applies stemming (recipes finds recipe) and removes stop words (the, a, in do not match). For other languages, replace 'english' with 'french', 'german', etc.
Note: CREATE INDEX CONCURRENTLY on a table with existing data to avoid locking the table during index creation. On a table with 100K rows, index creation takes 10–60 seconds with CONCURRENTLY and does not block reads or writes.
Recent Searches
DatalocalStorage array of the last 5 search queries on web. SharedPreferences on Flutter. Shown in the search modal when the input is empty or has fewer than 3 characters. Each recent search is clickable and populates the input. A clear button removes the history. Recent searches are stored as strings with timestamps so stale entries can be pruned.
Note: Cap recent searches at 5 entries. A longer list makes the empty state feel cluttered and is rarely used — users remember their last 2–3 searches, not 10.
Search Analytics
BackendA search_events table logs query text, result count, and the slug of the clicked result. This data surfaces 'no results' queries — content gaps you can fill. Also reveals the most common search terms for SEO and content strategy. RLS restricts SELECT to an admin role so users cannot read each other's searches.
Note: Log search events asynchronously (fire-and-forget) so the analytics insert never blocks the search response. A failed analytics write should never surface as a user-facing error.
The data model
Two additions to your database: a tsvector column and GIN index on the content table, plus a search_events table for analytics. Run this in the Supabase SQL Editor — adapt 'content' to your actual table name:
1-- Add full-text search to your content table2-- Adjust 'content' to your actual table name and 'title','body' to your searchable columns3alter table public.content4 add column if not exists search_vector tsvector5 generated always as (6 to_tsvector('english',7 coalesce(title, '') || ' ' || coalesce(body, '') || ' ' || coalesce(tags::text, '')8 )9 ) stored;1011-- GIN index for fast full-text search12create index concurrently if not exists content_search_vector_idx13 on public.content using gin(search_vector);1415-- Run ANALYZE so PostgreSQL knows to use the new index16analyze public.content;1718-- Search analytics table19create table if not exists public.search_events (20 id uuid primary key default gen_random_uuid(),21 user_id uuid references auth.users(id) on delete set null,22 query text not null,23 result_count int not null default 0,24 clicked_slug text,25 session_id text,26 created_at timestamptz not null default now()27);2829alter table public.search_events enable row level security;3031-- Authenticated users can log their own search events32create policy "Authenticated users can insert search events"33 on public.search_events for insert34 to authenticated35 with check (auth.uid() = user_id);3637-- Admins can read all search events (replace 'admin' with your role check)38create policy "Admins can view all search events"39 on public.search_events for select40 to authenticated41 using (42 exists (43 select 1 from public.profiles44 where id = auth.uid() and role = 'admin'45 )46 );4748-- Index for analytics queries49create index search_events_query_idx on public.search_events (query, created_at desc);50create index search_events_no_results_idx on public.search_events (result_count, created_at desc)51 where result_count = 0;Heads up: The index on search_events where result_count = 0 is a partial index that makes it fast to pull 'zero-result queries' for content gap analysis. Run this query weekly to find what users search for but your content doesn't cover: SELECT query, COUNT(*) FROM search_events WHERE result_count = 0 GROUP BY query ORDER BY COUNT(*) DESC LIMIT 20.
Build it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
V0 generates the cmdk CommandDialog pattern natively — it is one of the most common shadcn/ui patterns. Keyboard navigation, highlighted results, and Algolia InstantSearch integration all handled correctly on the first prompt.
Step by step
- 1Paste the prompt below in V0 to generate the search command palette
- 2In the Vars panel, add NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and optionally NEXT_PUBLIC_ALGOLIA_APP_ID and NEXT_PUBLIC_ALGOLIA_SEARCH_KEY
- 3Run the SQL from this page in your Supabase SQL Editor to create the search_vector column and GIN index
- 4Publish to Vercel and test the Cmd+K shortcut — verify no parent element is stopping keyboard event propagation
- 5Test zero-results state and recent searches by clearing your search history and entering a term that matches nothing
Create a Next.js search feature using the cmdk library's CommandDialog component. Mark parent component 'use client'. Cmd+K global shortcut: attach keydown to window in useEffect with e.preventDefault() then setOpen(true); close on Escape. CommandDialog contains: CommandInput with 300ms debounce (useDebounce from usehooks-ts); inputs under 3 chars — show CommandGroup 'Recent searches' from localStorage (last 5 queries, stored as JSON array); inputs 3+ chars — show a loading state while fetching, then CommandGroup 'Results'. Fetch from Supabase using .textSearch('search_vector', query, { type: 'websearch', config: 'english' }) — NOT .ilike(). Result highlighting: write a highlight(text, query) function that splits text on the query term (case-insensitive) and returns an array of {segment, isMatch} — render matched segments in a font-semibold span; no dangerouslySetInnerHTML. Keyboard navigation is built into cmdk — ensure Enter calls router.push(result.path) and closes the dialog. No-results state: 'No results for query' with suggested searches. On result click: insert to search_events table (query, result_count, clicked_slug). Mobile: full-screen on sm breakpoint using Dialog fullScreen prop. URL sync: write the query to ?q= URL param via nuqs on Enter so search results pages have shareable URLs.Where this path bites
- V0 does not provision Supabase or Algolia automatically — the SQL index and env vars must be set up manually before the search will return results
- Algolia env vars must be set in the Vercel dashboard under the project's Environment Variables section, not just the Vars panel in V0
Third-party services you'll need
Supabase full-text search is free and covers most apps. Algolia or Typesense are upgrades for advanced ranking and typo tolerance:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase Full-Text Search | PostgreSQL tsvector search with GIN index — built-in, no extra service | Included in Supabase free and Pro plans | No additional cost beyond Supabase plan |
| Algolia | Advanced search with typo tolerance, ranking tuning, geo-search, and InstantSearch React components | 10K search requests/mo, 10K records | $0.50 per 1K requests above free tier (approx); Grow plan starts at $0.50/1K |
| Typesense Cloud | Open-source alternative to Algolia; self-hostable; lower cost at high volume | 3 searches/second; 1K records | $0.000010 per search (approx); $0.002/hr per node (approx) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase full-text search with GIN index handles 100 users at zero additional cost. Search queries are fast and well within Supabase free tier limits.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Search returns no results for plural queries
Symptom: Supabase .ilike('%recipes%') is an exact character match. It finds 'recipes' in the text but not 'recipe'. For a search feature, this is a critical failure — users expect 'recipe' to find 'recipes' and vice versa. This is the stemming problem. ILIKE has no stemming, and it also fails to match 'cooking' for a search for 'cook'.
Fix: Create a tsvector GENERATED column using to_tsvector('english', ...) — the English dictionary applies Porter stemming, normalizing 'recipes', 'recipe', and 'reciper' to the same stem. Use websearch_to_tsquery('english', userInput) for the query side, which applies the same stemming to the search term before matching.
Search is slow — queries take 2–5 seconds on tables with 10K+ rows
Symptom: Without a GIN index on the tsvector column, every search performs a sequential scan of the entire table, reading every row to find matches. At 10K rows this is noticeable (500ms–2s). At 100K rows it is unusable (5–30s). The symptom appears fine in development with 50 test records and only surfaces after real data volume is loaded.
Fix: Run CREATE INDEX CONCURRENTLY ON table_name USING GIN(search_vector) in the Supabase SQL Editor, then run ANALYZE. Query time drops from seconds to under 50ms for most datasets. The CONCURRENTLY keyword prevents table locking during index creation on tables with existing data.
Cmd+K shortcut works in V0 preview but not after Vercel deployment
Symptom: On the deployed app, another element on the page (a sidebar, a modal, or a third-party widget) calls e.stopPropagation() on keydown events, preventing the event from bubbling up to the window listener that opens the search. This does not happen in the V0 sandbox because the page structure is different.
Fix: Attach the keydown listener to window, not document.body or a specific element. Use capture phase if necessary: window.addEventListener('keydown', handler, true) — the third argument true makes it a capture listener that fires before any stopPropagation on the element level. Test with DevTools Event Listeners panel to find any element intercepting the event.
FlutterFlow search shows stale results after a record is updated
Symptom: FlutterFlow's Supabase query stores results in the page state on initial load and does not automatically re-query when the underlying data changes. If a user updates a record and then searches, the old version appears in results until the page is refreshed. Supabase tsvector is also not updated in real time for FlutterFlow page state.
Fix: Add a Rebuild Page action after any mutation that affects searchable content. Alternatively, add a Supabase Realtime listener that sets a flag when the content table changes, triggering a re-query. For critical search accuracy, invalidate the FlutterFlow page state cache on any write to the content table.
Search input loses focus on mobile every time results update
Symptom: On iOS Safari, when the result list re-renders below a search input and the DOM position of the input changes even slightly, the keyboard dismisses and the input loses focus. This creates a loop where every keystroke closes the keyboard. The symptom is invisible in the desktop Chrome mobile simulator but breaks immediately on a real iPhone.
Fix: Wrap the result list in a container with a fixed height or position:absolute so the list renders without affecting the input's DOM position. Use position: sticky on the search bar. Test on a real iOS device — do not rely on the Simulator or DevTools mobile mode to catch this bug, as both fail to reproduce it reliably.
Best practices
Use websearch_to_tsquery('english', userInput) for user-facing search — it handles natural language input ('chicken recipes') without requiring query syntax ('chicken & recipes')
Create the GIN index before loading production data — index creation is fast on an empty table and takes 10–60 seconds with CONCURRENTLY on a large one
Run ANALYZE after creating the GIN index to update PostgreSQL's query planner statistics, otherwise the planner may ignore the index
Cap the search debounce at 300ms and skip queries under 3 characters — these two changes alone reduce database load by 60–80% compared to searching on every keystroke
Never use dangerouslySetInnerHTML for term highlighting — split the result string into segments and map to React elements to prevent XSS from user-controlled query strings
Log zero-result queries to the search_events table with a partial index — this is the highest-value dataset in any content product and typically reveals the top 5 content gaps within a week of launch
Serialize filter state to URL params via nuqs — this makes every filtered search result shareable and makes the browser back button work correctly within the search experience
When You Need Custom Development
Supabase full-text search covers single-collection keyword search well. Four scenarios require stepping up to custom infrastructure:
- Search must span heterogeneous sources in a unified result set: database records, uploaded PDF content extracted via a parsing pipeline, S3 files with metadata, and external API results — this requires an indexing pipeline that Supabase FTS cannot cover
- Semantic search is needed — user types 'cozy coffee spot' and results include 'warm café ambience' without the exact words — requires pgvector cosine similarity or a dedicated vector search service like Pinecone
- Enterprise compliance requirement: search queries must be logged with user attribution, retained for a specified period, and searchable themselves with RBAC on query log access
- Multi-language search with correct stemming for non-English languages including Japanese (tokenization), Arabic (root extraction), or German (compound word splitting) — each language requires a different text search configuration
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
What is the difference between ILIKE search and full-text search in Supabase?
ILIKE is a case-insensitive exact character match: .ilike('%recipe%') finds rows where the word 'recipe' appears literally. Full-text search with tsvector applies language processing: stemming (recipe and recipes match the same stem), stop word removal (the, a, in are ignored), and ranking by relevance. ILIKE also requires a sequential table scan on every query — no index can speed it up for middle-wildcard patterns. Full-text search with a GIN index returns results in under 50ms regardless of table size.
Should I use Algolia or Supabase for search?
Supabase full-text search is the right starting point for most apps — it is free, already in your stack, and handles stemming, stop words, and ranking for English text. Switch to Algolia when you need typo tolerance (user types 'reccipe' and still finds results), multi-language ranking tuning, geo-search (find results near the user's location), or when your search has marketing requirements (A/B test different ranking formulas). Algolia's free tier covers 10K searches/month — many apps never exceed it.
How do I add search to a mobile app built with FlutterFlow?
Use a Supabase Query action with a .textSearch filter on the search_vector column. The setup steps: create the tsvector column and GIN index in the Supabase SQL Editor (this cannot be done in FlutterFlow's visual builder), then add a TextField widget for the search input and bind the Supabase query to its value with a 300ms debounce via a FlutterFlow Action Timer. Display results in a ListView.builder below the search field.
Can search results show highlighted matching text snippets?
Yes. Supabase has a ts_headline() PostgreSQL function that extracts and highlights matching snippets from the full text: SELECT ts_headline('english', body, websearch_to_tsquery('english', 'recipe chicken'), 'MaxWords=20,MinWords=10') AS snippet. This returns a short excerpt with the matching terms wrapped in <b> tags. On the client, render the snippet with the bold tags parsed into React elements rather than using dangerouslySetInnerHTML.
How do I search across multiple tables — users AND posts?
Create a unified search view in PostgreSQL that UNIONs rows from both tables into a common shape: id, type ('user' or 'post'), title, body, search_vector. Add a GIN index on the view's search_vector. Query the view instead of individual tables. This approach works for 2–3 tables. For more sources or heterogeneous schemas, Algolia or Typesense with separate index collections is cleaner.
Does full-text search support typo tolerance?
PostgreSQL full-text search does not support typo tolerance natively. A search for 'recpie' will not find 'recipe'. For typo tolerance at the Supabase level, you can use the pg_trgm extension's similarity function, but it is slower than GIN-indexed tsvector and requires a different index type. For robust typo tolerance, Algolia or Typesense handle it at the index level with no query-time performance cost.
How do I track what users are searching for?
Insert a row into the search_events table (included in the data model above) after each search: query text, result count, and the slug of any result the user clicks. Run this insert as a fire-and-forget call so it never blocks the search response. The resulting table gives you zero-result queries for content gaps, popular terms for SEO content, and click-through rate per result position for ranking analysis.
Can I add voice search on top of this?
Yes. Voice search is a separate input method that feeds the same search backend. Use the Web Speech API (SpeechRecognition) on the browser to transcribe speech to text, then pass the transcription to the same debounced search function. Add a microphone button to the search input. The main caveat: SpeechRecognition is not available on Firefox and has limited support on iOS Safari — provide the text input as the primary path and voice as an enhancement.
Need this feature production-ready?
RapidDev builds search into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.