Supabase supports full-text search through PostgreSQL's built-in tsvector and tsquery system. Add a tsvector column to your table, create a GIN index for fast lookups, and use the textSearch filter in the Supabase JS client to search across text columns. For multi-column search, combine multiple columns into a single tsvector using concatenation. PostgreSQL handles stemming, stop words, and ranking automatically.
Implementing Full-Text Search in Supabase with tsvector and GIN Indexes
PostgreSQL includes a powerful full-text search engine that supports stemming, stop word removal, and relevance ranking. Supabase exposes this through the textSearch filter in the JS client and the to_tsvector/to_tsquery SQL functions. This tutorial shows you how to add full-text search to your tables, index them for performance, and query them from your frontend. No external search service is needed.
Prerequisites
- A Supabase project (free tier works)
- A table with text columns you want to search
- Access to the SQL Editor in the Supabase Dashboard
- @supabase/supabase-js v2 installed in your project
Step-by-step guide
Add a tsvector column to your table
Add a tsvector column to your table
A tsvector column stores pre-processed text tokens optimized for search. Add a generated tsvector column that automatically updates when the source text columns change. Use the to_tsvector function with a language configuration (typically 'english') to process the text into searchable tokens. A generated column means you never need to update it manually — it always reflects the current data.
1-- Example: articles table with full-text search2create table public.articles (3 id bigint generated always as identity primary key,4 title text not null,5 body text not null,6 author_id uuid references auth.users(id),7 created_at timestamptz default now(),8 -- Generated tsvector column combining title and body9 fts tsvector generated always as (10 setweight(to_tsvector('english', coalesce(title, '')), 'A') ||11 setweight(to_tsvector('english', coalesce(body, '')), 'B')12 ) stored13);1415alter table public.articles enable row level security;1617create policy "Public read articles" on public.articles18 for select to anon, authenticated19 using (true);Expected result: The articles table has an fts column that automatically generates a tsvector from the title and body whenever a row is inserted or updated.
Create a GIN index for fast full-text search
Create a GIN index for fast full-text search
Without an index, PostgreSQL performs a sequential scan on every search query, which is slow for large tables. A GIN (Generalized Inverted Index) index on the tsvector column dramatically speeds up full-text search by creating an inverted index of all tokens. This is the standard index type for full-text search in PostgreSQL.
1-- Create a GIN index on the tsvector column2create index idx_articles_fts on public.articles using gin (fts);34-- Verify the index was created5select indexname, indexdef6from pg_indexes7where tablename = 'articles';Expected result: The GIN index is created. Full-text search queries now use the index instead of scanning the entire table.
Query with the Supabase JS client's textSearch filter
Query with the Supabase JS client's textSearch filter
The Supabase JS client provides a textSearch filter that translates to PostgreSQL's @@ operator. Pass the column name (the tsvector column) and the search query. The client automatically converts your search string into a tsquery. You can specify the query type: plain (default, AND between words), phrase (exact phrase match), or websearch (Google-style syntax with quotes, OR, and minus for exclusion).
1import { createClient } from '@supabase/supabase-js'23const supabase = createClient(4 process.env.NEXT_PUBLIC_SUPABASE_URL!,5 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!6)78// Basic search: all words must match9const { data: results } = await supabase10 .from('articles')11 .select('id, title, body, created_at')12 .textSearch('fts', 'supabase database')1314// Phrase search: exact phrase match15const { data: phrase } = await supabase16 .from('articles')17 .select('id, title')18 .textSearch('fts', 'row level security', { type: 'phrase' })1920// Websearch: Google-style syntax21const { data: web } = await supabase22 .from('articles')23 .select('id, title')24 .textSearch('fts', '"edge functions" OR "serverless" -lambda', {25 type: 'websearch'26 })Expected result: Each query returns an array of articles matching the search criteria. Only rows where the tsvector matches the tsquery are returned.
Rank search results by relevance
Rank search results by relevance
By default, textSearch returns results in the table's natural order, not ranked by relevance. To rank results, create a database function that uses ts_rank or ts_rank_cd to score each row based on how well it matches the query. Call this function via supabase.rpc() and order by the rank score. The weights assigned to the tsvector columns (A, B, C, D) affect the ranking.
1-- Create a search function with ranking2create or replace function search_articles(search_query text)3returns table (4 id bigint,5 title text,6 body text,7 rank real8)9language sql10as $$11 select12 a.id,13 a.title,14 a.body,15 ts_rank(a.fts, websearch_to_tsquery('english', search_query)) as rank16 from public.articles a17 where a.fts @@ websearch_to_tsquery('english', search_query)18 order by rank desc19 limit 20;20$$;2122-- Call from the JS client23const { data } = await supabase.rpc('search_articles', {24 search_query: 'supabase edge functions'25})Expected result: Results are returned ordered by relevance rank, with articles that match in the title scoring higher than articles that only match in the body.
Add search highlighting with ts_headline
Add search highlighting with ts_headline
Use the ts_headline function to generate highlighted search snippets that show the matched terms in context. This is useful for displaying search results with bolded keywords, similar to how Google highlights search terms in its results. ts_headline takes the original text, the query, and formatting options.
1-- Search function with highlighted snippets2create or replace function search_articles_with_highlights(search_query text)3returns table (4 id bigint,5 title text,6 snippet text,7 rank real8)9language sql10as $$11 select12 a.id,13 a.title,14 ts_headline(15 'english',16 a.body,17 websearch_to_tsquery('english', search_query),18 'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15'19 ) as snippet,20 ts_rank(a.fts, websearch_to_tsquery('english', search_query)) as rank21 from public.articles a22 where a.fts @@ websearch_to_tsquery('english', search_query)23 order by rank desc24 limit 20;25$$;2627const { data } = await supabase.rpc('search_articles_with_highlights', {28 search_query: 'database security'29})30// data[0].snippet: "...configure <mark>database</mark> <mark>security</mark> policies..."Expected result: Each search result includes a snippet with matched terms wrapped in <mark> tags, ready for display in a search results UI.
Complete working example
1-- Articles table with full-text search2create table public.articles (3 id bigint generated always as identity primary key,4 title text not null,5 body text not null,6 author_id uuid references auth.users(id),7 created_at timestamptz default now(),8 fts tsvector generated always as (9 setweight(to_tsvector('english', coalesce(title, '')), 'A') ||10 setweight(to_tsvector('english', coalesce(body, '')), 'B')11 ) stored12);1314alter table public.articles enable row level security;15create policy "Public read articles" on public.articles16 for select to anon, authenticated using (true);17create policy "Auth insert articles" on public.articles18 for insert to authenticated with check (true);1920-- GIN index for fast full-text search21create index idx_articles_fts on public.articles using gin (fts);2223-- Ranked search function24create or replace function search_articles(search_query text)25returns table (id bigint, title text, body text, rank real)26language sql as $$27 select a.id, a.title, a.body,28 ts_rank(a.fts, websearch_to_tsquery('english', search_query)) as rank29 from public.articles a30 where a.fts @@ websearch_to_tsquery('english', search_query)31 order by rank desc32 limit 20;33$$;3435-- Search with highlighted snippets36create or replace function search_articles_with_highlights(search_query text)37returns table (id bigint, title text, snippet text, rank real)38language sql as $$39 select a.id, a.title,40 ts_headline('english', a.body,41 websearch_to_tsquery('english', search_query),42 'StartSel=<mark>, StopSel=</mark>, MaxWords=35, MinWords=15'43 ) as snippet,44 ts_rank(a.fts, websearch_to_tsquery('english', search_query)) as rank45 from public.articles a46 where a.fts @@ websearch_to_tsquery('english', search_query)47 order by rank desc48 limit 20;49$$;Common mistakes when doing Full-Text Search in Supabase
Why it's a problem: Not creating a GIN index on the tsvector column, causing full-text search to be slow on large tables
How to avoid: Always create a GIN index: CREATE INDEX idx_name ON table USING gin (fts_column). Without it, PostgreSQL scans every row.
Why it's a problem: Searching on the raw text column instead of the tsvector column with textSearch
How to avoid: Use textSearch on the tsvector column (e.g., 'fts'), not on the raw text columns (e.g., 'title' or 'body'). The tsvector column is what the search engine reads.
Why it's a problem: Using ilike for search instead of full-text search, missing stemming and ranking capabilities
How to avoid: Use textSearch for proper full-text search. ilike does simple pattern matching without stemming ('running' would not match 'run') and cannot rank results.
Why it's a problem: Forgetting to wrap text columns in coalesce() when building the tsvector, causing errors on NULL values
How to avoid: Always use coalesce(column, '') when building tsvector from nullable columns. to_tsvector fails on NULL input.
Best practices
- Use generated tsvector columns so the search index updates automatically when source columns change
- Create a GIN index on every tsvector column for fast search performance
- Assign weights with setweight to prioritize title matches over body matches in relevance ranking
- Use websearch_to_tsquery for user-facing search boxes — it supports Google-style syntax naturally
- Create database functions with ts_rank for relevance-ordered results and call them via supabase.rpc()
- Use ts_headline to generate highlighted snippets for search result display
- Wrap nullable columns in coalesce() when building tsvector to prevent NULL errors
- For multilingual content, store the language configuration per row and use it dynamically in the tsvector
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I want to add full-text search to my Supabase articles table. Show me how to create a tsvector column combining title and body, add a GIN index, query with the JS client's textSearch filter, and create a database function that returns results ranked by relevance with highlighted snippets.
Create a Supabase articles table with a generated tsvector column combining title (weight A) and body (weight B), a GIN index, and two database functions: one for ranked search and one for search with ts_headline highlighted snippets. Show the JS client calls for textSearch and rpc.
Frequently asked questions
Does Supabase full-text search handle different word forms like 'running' and 'run'?
Yes. PostgreSQL's full-text search includes stemming, which reduces words to their root form. A search for 'running' matches documents containing 'run', 'runs', 'running', and 'ran' when using the English language configuration.
Can I search across multiple tables at once?
Not directly with a single query. Create a database function that searches multiple tables and returns a unified result set using UNION ALL, or create a materialized view that combines the data.
What is the difference between textSearch types: plain, phrase, and websearch?
Plain requires all words to be present (AND logic). Phrase requires words to appear in exact order. Websearch supports Google-style syntax with double quotes for exact phrases, OR for alternatives, and dash prefix for exclusion.
Do I need an external search service like Algolia or Typesense?
For most applications, PostgreSQL's built-in full-text search is sufficient. External search services offer features like typo tolerance, faceted search, and geo-search that PostgreSQL does not provide natively.
How do I handle multilingual content in full-text search?
Store the language configuration per row (e.g., a 'language' column) and use it dynamically: to_tsvector(language_column, text_column). PostgreSQL supports many language configurations out of the box.
Can I combine full-text search with other Supabase filters?
Yes. Chain textSearch with other filters like eq, gt, or order: supabase.from('articles').select('*').textSearch('fts', 'query').eq('status', 'published').order('created_at', { ascending: false }).
Can RapidDev help implement full-text search in my Supabase project?
Yes. RapidDev can design your search schema, create weighted tsvector columns and GIN indexes, build ranked search functions, and implement search UIs with highlighted results.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation