# Full-Text Search PostgreSQL FTS with Supabase for Next.js 14 — weighted tsvector search, websearch query format, autocomplete, facet counts, client-side highlight extraction, recent searches, and a debounced React hook with pagination. ## What's included **Core search** - `fullTextSearch(query, opts)` — runs a `textSearch` query using Supabase's `websearch` mode; applies additional `eq`/`in` filters; returns a typed `SearchResult` with `items`, `total`, `page`, `totalPages`, `timingMs` - `autocomplete(query, table, column, limit?, extra?)` — `ilike` prefix match on a column; deduplicates results; returns a flat string array; short-circuits below 2 chars **Facets & filters** - `getFacetCounts(table, facetColumns, baseFilters?)` — counts distinct values per column in parallel; returns `{ [column]: { [value]: count } }`; use to populate filter sidebar checkboxes **Text utilities** - `sanitizeQuery(query)` — strips PostgreSQL FTS operator characters, normalises whitespace, caps at 256 chars; applied internally before every search - `highlightMatches(text, query, opts?)` — extracts a context window around the first match and wraps matched terms in `` (or a custom tag); accepts `maxLength`, `contextPad`, `tag`, `minWordLen` **Recent searches** - `getRecentSearches(max?)` — reads from `localStorage`; returns up to 10 entries - `addRecentSearch(query)` — prepends to the list, deduplicates, caps at 10 - `clearRecentSearches()` — removes the key **React hook** - `useSearch(opts)` — returns `{ query, setQuery, results, total, loading, error, page, setPage, totalPages, hasMore, nextPage, prevPage, reset, recentSearches }`; debounces input (default 300ms); saves non-empty queries to recent searches; re-runs when `filters` or `table`/`columns` change **Types** - `SearchOptions` — input config for `fullTextSearch` - `SearchResult` — typed result envelope - `FacetCounts` — `{ [column]: { [value]: count } }` - `HighlightOptions` — options for `highlightMatches` ## Setup ### 1. Install dependencies ```bash npm install @supabase/supabase-js ``` ### 2. Environment variables ``` NEXT_PUBLIC_SUPABASE_URL=your Supabase project URL NEXT_PUBLIC_SUPABASE_ANON_KEY=anon public key ``` ### 3. Database The SQL below uses a `posts` table as an example — adapt the column names and weights to your own table. The pattern (generated `fts` column + GIN index + optional trigram index) is the same regardless. ```sql CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE TABLE IF NOT EXISTS posts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES profiles(id), title TEXT NOT NULL, content TEXT NOT NULL, tags TEXT[] DEFAULT '{}', category TEXT, published BOOLEAN NOT NULL DEFAULT true, view_count INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ALTER TABLE posts ADD COLUMN IF NOT EXISTS fts tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(content, '')), 'B') || setweight(to_tsvector('english', array_to_string(coalesce(tags, '{}'), ' ')), 'C') ) STORED; CREATE INDEX IF NOT EXISTS posts_fts_gin ON posts USING GIN(fts); CREATE INDEX IF NOT EXISTS posts_title_trgm ON posts USING GIN(title gin_trgm_ops); ``` ## Usage examples ```tsx // Search page with debounced hook 'use client' import { useSearch, highlightMatches } from '@/blocks/search' export function PostSearch() { const { query, setQuery, results, total, loading, nextPage, prevPage, page, totalPages } = useSearch({ table: 'posts', columns: 'id, title, content, category, created_at', filters: { published: true }, limit: 20, debounceMs: 300, }) return ( <> setQuery(e.target.value)} placeholder="Search posts…" />

{total} results

{results.map(post => (

{post.title}

))} ) } ``` ```ts // Faceted filter sidebar import { getFacetCounts } from '@/blocks/search' const facets = await getFacetCounts('posts', ['category', 'tags'], { published: true }) // facets.category → { 'Tutorial': 12, 'Release': 4 } // facets.tags → { 'nextjs': 9, 'supabase': 7 } ``` ```ts // Autocomplete suggestions import { autocomplete } from '@/blocks/search' const suggestions = await autocomplete('nex', 'posts', 'title', 8, { published: true }) // → ['Next.js 14 App Router', 'Next.js API Routes', ...] ``` ## Notes - `fullTextSearch` uses the Supabase anon client — your `posts` (or whatever table) must have an RLS `SELECT` policy that permits the query, otherwise results will silently come back empty - `highlightMatches` returns a string with raw HTML tags — render it with `dangerouslySetInnerHTML`; the input `text` comes from your own database so XSS risk is low, but sanitize if users can write the content being searched - `getFacetCounts` fetches the entire column for the table (no `SELECT count` aggregation) — this is fine for small tables but will be slow and memory-intensive on large ones; replace with a `GROUP BY` RPC function for anything over ~50k rows - `useSearch` memoizes on `JSON.stringify(opts.filters)` — avoid passing a new object literal on every render or the search will fire on every parent re-render; define `filters` outside the component or with `useMemo`