search: block to an entity, get BM25 ranking + live facet counts + sort across millions of rows — without running Meilisearch, ElasticSearch, or any external index. The index is maintained inside the same SQLite/Postgres database, in the same transaction as your writes.
Declaring a searchable entity
Searching from the client
db.useSearch is the React hook. Returns ranked hits, per-facet counts, total, and timing.
Searching from a server function
ctx.db.search. Useful when you need to search inside a transaction or pre-aggregate before returning.
How it works
- Text matching: SQLite FTS5 / Postgres
tsvector. BM25 ranking by default, configurable per field. - Facets: roaring bitmaps stored in a
_facet_bitmaptable, one bitmap per(entity, column, value). Intersection across active filters is bit-AND, which is faster thanWHEREclauses by 10–100× at scale. - Sort + paginate: when the planner needs to sort across the entire match set, it materializes hit ids into a temp table, joins back to the row table, applies
ORDER BY+LIMIT/OFFSET. This is the only way to paginate consistently across a sorted projection. - Aggregation safety: faceted search refuses to run on entities whose read policy depends on per-row data. Otherwise, facet counts would leak the existence of rows the caller can’t read. To opt in, make your read policy row-independent (e.g.
auth.userId != null) or scope reads with a server function.
API
POST /api/search/:entity accepts:
Performance
Native search trades the operational cost of a separate index server for slightly higher per-query latency on enormous datasets (>10M rows). For the ~99% of B2B SaaS workloads (10K–10M rows), it’s faster end-to-end because there’s no network hop, no async indexing lag, and no second system to monitor. Theexamples/store example runs a 10,000-product catalog with 3-facet search at sub-5ms p95 on a $5 VPS.
Next
Faceted search example
Walk through the store with code highlights.
Live queries
How search results stay up to date.