SearchIntermediatean afternoon

Add search to your app

Search lets users find things in your app — products, docs, notes, orders — by typing words instead of scrolling or clicking through menus. Weak or missing search quietly kills retention: people expect a box that returns the right result instantly, and when it doesn't, they assume the thing they wanted isn't there.

the approach

Don't reach for Elasticsearch or Algolia on day one. Match the tool to the need and climb a ladder only as far as you actually have to. Rung 1: if you're already on Postgres/Supabase, use Postgres full-text search — it's free, transactional, and good enough for months. Rung 2: move to a dedicated engine (Meilisearch or Typesense) when you need typo tolerance, faceting, and sub-50ms instant-search-as-you-type. Rung 3: add semantic/vector search only when keyword matching demonstrably fails on natural-language queries — and even then prefer hybrid (keyword + vector), because pure vector search is bad at exact matches like names, SKUs, and error codes. The hard part is never the query; it's keeping the index in sync and respecting per-user access control.

Step by step

  1. 1

    Define what "search" actually means here

    Write down which fields are searchable (title, body, tags), what users filter by (status, category, date), how results should rank, and how fresh they must be. "Search" for a 500-row admin table and "search" for a public product catalog are different projects — decide which one you're building before writing code.

  2. 2

    Start with Postgres full-text search

    If you're on Postgres or Supabase, add a generated tsvector column with weighted fields plus a GIN index, and query it with websearch_to_tsquery. Ship that, put it in front of real users, and measure. Most apps never need to leave this rung.

  3. 3

    Upgrade to a dedicated engine only when you hit a real wall

    When you need typo tolerance ("reciept" → "receipt"), faceted filters, or true instant-search, move the searchable data into Meilisearch or Typesense. Keep Postgres as your source of truth; the engine is just a fast, denormalized copy of the fields you search.

  4. 4

    Keep the index in sync — this is the part people get wrong

    Reindex on every create, update, AND delete. Don't fire-and-forget from your request handler; use an outbox table or a queue (or a Postgres trigger + webhook) so a failed sync retries instead of silently drifting. Stale or deleted rows showing up in results is the #1 search bug.

  5. 5

    Build a UI that feels instant

    Debounce the input (~150–250ms), cancel or ignore stale responses so a slow earlier request can't overwrite a newer one, highlight the matched terms, and always render explicit loading, empty, and zero-result states. Add keyboard navigation (arrow keys + Enter) — power users live in the search box.

  6. 6

    Enforce access control inside the query

    Search bypasses your normal per-page authorization, so every query must filter by tenant/user/permission itself. A user searching "salary" must never surface another org's rows. Bake the ownership filter into the WHERE clause or the engine's filter, not into post-processing you might forget.

  7. 7

    Add semantic/hybrid search last, if at all

    Only when keyword search visibly fails on natural-language queries, add vector search via pgvector or your engine's built-in hybrid mode. Combine keyword and vector scores rather than replacing one with the other, so exact identifiers still win.

  8. 8

    Instrument and tune

    Log every query and especially zero-result searches. That log is your backlog: it tells you which synonyms to add, which ranking weights to bump, and what content users expect but you don't have.

Starter snippet

sql
-- Add a weighted, generated search column + GIN index (Postgres / Supabase)
alter table articles add column search tsvector
  generated always as (
    setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(body,  '')), 'B')
  ) stored;

create index articles_search_idx on articles using gin (search);

-- Query it. websearch_to_tsquery parses "quoted phrases" and OR like a
-- real search box. $1 is the raw user string, passed as a parameter --
-- it's data, not interpolated SQL, so there's nothing to escape.
select id, title,
       ts_rank(search, websearch_to_tsquery('english', $1)) as rank
from articles
where search @@ websearch_to_tsquery('english', $1)
  and org_id = $2              -- always scope to the caller
order by rank desc
limit 20;

✕ Watch out for

  • Reaching for Elasticsearch or Algolia on day one when a GIN index on Postgres would have carried you for months of over-engineering you now have to operate.
  • Index drift: forgetting to reindex on update and delete, so search happily returns stale content or rows the user already deleted.
  • Leaking data across users or tenants because search skips your normal authz — the ownership filter must live inside the search query itself.
  • Using pure vector/semantic search for everything; it's genuinely bad at exact matches like emails, SKUs, and error codes. Use hybrid.
  • Firing a query on every keystroke with no debounce and no stale-response guard, which hammers your DB and lets an old response overwrite a newer one.

✓ Pro tips

  • Tell your AI agent exactly which stack you're on ("Supabase Postgres, Next.js, Drizzle") and explicitly say: "start with Postgres full-text search, do NOT add a new service unless I ask." Left alone, agents love to scaffold Algolia for a 300-row table. Then verify two things by hand: that it actually created a GIN index (no index = full table scans that look fine on your dev data and melt in prod), and that writes reindex.
  • Ask the agent to write a test that inserts a row, searches and finds it, updates the row, searches again, then deletes it and confirms it's gone from results. This catches index-sync bugs that are invisible in a quick manual click-through and are the single most common search defect.
  • Log zero-result queries from day one and review them weekly — it's the cheapest, highest-signal roadmap you'll get for synonyms, missing content, and ranking tweaks.