# 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<T>(query, opts)` — runs a `textSearch` query using Supabase's `websearch` mode; applies additional `eq`/`in` filters; returns a typed `SearchResult<T>` 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 `<mark>` (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<T>(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<T>` — 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<Post>({
table: 'posts',
columns: 'id, title, content, category, created_at',
filters: { published: true },
limit: 20,
debounceMs: 300,
})
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search posts…" />
<p>{total} results</p>
{results.map(post => (
<div key={post.id}>
<h3>{post.title}</h3>
<p dangerouslySetInnerHTML={{ __html: highlightMatches(post.content, query) }} />
</div>
))}
<button onClick={prevPage} disabled={page === 1}>← Prev</button>
<button onClick={nextPage} disabled={page === totalPages}>Next →</button>
</>
)
}
```
```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`