Dev In The Mountain Header
A Developer In The mountains having fun

Client-Side Search & Database Options for Static Sites β€” Master Report

1. Executive summary

A static site has no server to run queries, so every option either ships an index or database to the browser and queries it in memory, or queries a file on the static host in small pieces (a chunked index, or HTTP Range requests into a single file).

If you need…UseOne-line reason
A search box over the site's pagesPagefindWorks after any generator, chunked index, ~100–300 kB per search even on 10k-page sites
Search over a structured JSON dataset (< ~5k records)MiniSearch~6 kB, real ranking, prefix + fuzzy + autosuggest, prebuilt index
Typo-tolerant lookup over short strings (command palette, titles)Fuse.jsBest fuzzy matching; but it scans linearly, so it isn't a full-text engine
Raw speed / very large in-memory index / IndexedDB cachingFlexSearch 0.8Fastest of the group, persistent indexes
Facets, filters, typo tolerance, or vector/hybrid searchOramaHas the most built in (~26 kB gz)
Real SQL over a large read-only datasetDuckDB-WASM with remote ParquetFetches only the columns and row groups a query needs
Real SQL (joins, sorts, FTS5) over a small-to-medium datasetOfficial SQLite WASMFull SQLite; the whole .sqlite file downloads once
Local-first app that writes and persists dataOfficial SQLite WASM + OPFS (or PGlite if you need Postgres)Durable, transactional storage in the browser
Analytics and dashboards over Parquet/CSVDuckDB-WASM (heavy) or hyparquet (light, read-only)Columnar Range-request reads

Combining tools is normal. A common pattern is Pagefind for site-wide search plus MiniSearch or SQLite for one data-heavy page.


2. Architectural families

FamilyHow it worksExamplesHow download scales
A. Chunked static indexA post-build crawler writes an index split into small files; the client fetches only the chunks a query needsPagefindSub-linear: a small slice per search
B. In-memory inverted indexA prebuilt JSON index is loaded whole and queried in memoryMiniSearch, FlexSearch, OramaLinear: the whole index downloads up front
C. Linear fuzzy scanRaw records are loaded and every query scans all of themFuse.jsLinear in data, and in query time too
D. SQLite in WASMA .sqlite file is queried with full SQL in the browsersql.js, official SQLite WASM, wa-sqlite / SQLocalLinear: the whole DB file downloads once
E. Columnar reads over Range requestsParquet/CSV is read in pieces with HTTP Range requestsDuckDB-WASM, hyparquetSub-linear: only needed columns and row groups
F. Postgres in WASMFull Postgres compiled to WASM, persisted locallyPGliteHeavy fixed runtime, plus your data
G. Client-side vector searchEmbeddings precomputed at build time, query embedded in-browserOrama (vector plugin), client-vector-search, altor-vec, Transformers.jsLinear: vectors ship as JSON or binary

3. Master comparison table

Versions are from the npm registry and sizes are measured gzip, both as of 2026-10-23. "Fixed cost" is the library or runtime only; your index or data is extra and is usually the bigger number for families B, C and G.

ToolFamilyLatest version (date)Fixed client cost (gzip, measured)Query modelBrowser persistenceMaintenance signal
PagefindA1.5.2 (2026-04-12)Runtime + chunks; ~100–300 kB total per search (project's figure)Full-text, stemming, prefix, filters, facets, multi-siteN/A (fetched on demand)Very active; 1.5 added web-component UI, Web Worker search, CJK segmentation, diacritics, smaller indexes
MiniSearchB7.2.0 (2025-09-16)~6 kBBM25-style, prefix, fuzzy (edit distance), field boosts, autosuggestSerialize to JSONActive, stable
Fuse.jsC7.5.0 (2026-07-13)~9 kBBitap fuzzy, weighted keys, extended operatorsPrebuilt index JSONVery active (7.2 plugins, 7.3 Fuse.match(), 7.4 Web Workers)
FlexSearchB0.8.212 (2025-09-06)~4.5 kB (light) to ~18 kB (bundle)Contextual full-text, resolvers, tags, workersIndexedDB (0.8+)Active; 0.7 β†’ 0.8 is a breaking migration
OramaB / G3.1.18 (2025-12-19)~26 kB (core ESM)BM25, typo tolerance, facets, filters, geo, vector/hybridSerialize via pluginActive; has a commercial cloud alongside
sql.jsD1.15.1 (2026-10-19)~335 kB WASMFull SQLite SQL (in memory)None built inActive
Official SQLite WASMD3.54.1 (2026-10-19)~406 kB WASMSQL, FTS5, JSON functionsOPFS (Worker)Official SQLite subproject, tracks SQLite releases
wa-sqlite / SQLocalD1.0.0 (2024) / 0.18.0 (2026-04)~similar to official buildSQL, pluggable VFSes (OPFS, IndexedDB)OPFS / IndexedDBwa-sqlite develops mainly on GitHub; SQLocal is a friendly wrapper over official WASM
DuckDB-WASME1.33.x (2026-06)~8 MB (EH build) to ~9 MB (MVP build); 36–41 MB uncompressedVectorized analytical SQL, Parquet/CSV/JSON/ArrowOPFS (optional)Active
hyparquetE1.31.5 (2026-10-18)~20 kBRead Parquet columns and rows via Range requests; not SQLN/AVery active; zero dependencies
PGliteF0.5.11 (2026-10-21)~5.3 MB (3.4 MB WASM + 1.9 MB data)Full Postgres, pgvector, PostGIS extensionsIndexedDB / OPFSVery active (ElectricSQL)

4. Tool-by-tool findings

4.1 Pagefind

What it is. A Rust CLI that runs after your generator, crawls the built HTML, and writes a pagefind/ folder containing a fragmented index, a JS/WASM search API, and optional UI.

How it works. On first search the client loads a small metadata file, around 10–30 kB, that maps term prefixes to index chunks. Then it fetches only the chunks matching the query terms. The project states a 10,000-page site stays under ~300 kB of total transfer, usually nearer 100 kB.

Strengths

  • Framework-agnostic, because it only needs HTML output. It's distributed as a standalone binary, on npm, and as Python bindings, so Hugo users don't need Node.
  • data-pagefind-body, data-pagefind-ignore, data-pagefind-filter and data-pagefind-meta control what gets indexed and exposed.
  • Multilingual with zero config, faceted filters, and multi-site search.
  • New in 1.5: a web-component search UI (the older Default and Modular UIs still work), improved ranking, diacritics support, automatic CJK segmentation, search in a Web Worker, smaller indexes, and faster indexing. 1.5.2 fixed a musl-allocator slowdown in Linux builds.
  • It's the default search in Astro Starlight, which is a strong adoption signal.

Weaknesses

  • It doesn't run in the dev server. The index exists only after a real build, so test with build && preview.
  • Matching is prefix plus stemming, not true edit-distance fuzzy. A typo that changes the term prefix finds nothing.
  • It only indexes rendered pages, so it can't query records that never become HTML.
  • Bundlers can choke on its runtime import (a known Next.js issue, "Failed to load Pagefind metadata"). Use a bundler-ignored dynamic import.
  • The index is tied to the build. Any content or layout change needs the post-build step in CI.

Best for: blogs, docs, knowledge bases, i.e. "search the pages".

4.2 MiniSearch

What it is. A tiny, dependency-free, in-memory full-text engine built on a radix tree and an inverted index.

Strengths

  • BM25-style ranking, prefix search, fuzzy search by edit distance, field boosting and restriction, and autoSuggest.
  • Pluggable tokenizer, term processing and field extraction, which helps with non-English text.
  • Build the index at compile time, then load it in the browser with MiniSearch.loadJSON.
  • Supports adding, removing and replacing documents, which suits dynamic data.
  • Query time depends on matching postings, not total document count, so queries take under a millisecond for thousands of records.

Weaknesses

  • The whole index downloads up front and lives in memory. That's fine for a few thousand records but gets costly for large corpora with long bodies.
  • No UI and no chunking.

Best for: catalogs, recipe or glossary collections, and small-to-medium content sites where you want ranking control.

4.3 Fuse.js

What it is. A zero-dependency approximate string matcher using the Bitap algorithm.

Strengths

  • The best typo tolerance on short strings: names, titles, tags, commands.
  • Weighted keys, extended search syntax (exact, prefix, inverse), logical queries, and match highlighting.
  • Actively developed: v7.2 added Fuse.use() plugins, v7.3 a static Fuse.match(), v7.4 Web Worker search with typed entry points, and 7.5.0 shipped July 2026.

Weaknesses

  • Not a full-text engine. There's no inverted index, and each query is O(N Γ— string length), so latency grows linearly with the data.
  • It gives noisy, CPU-heavy results on long article bodies.

Best for: command palettes and quick filters over a few hundred to a few thousand short items.

4.4 FlexSearch

What it is. A full-text library built for raw throughput.

Strengths

  • Consistently near the top of JS search benchmarks.
  • The v0.8 rewrite added persistent indexes (IndexedDB in the browser; Redis, SQLite, Postgres, MongoDB and ClickHouse on servers), worker indexes, export/import, a Resolver for boolean queries, and an Encoder class for language customization.
  • Multiple builds: light is ~4.5 kB gz and bundle ~18 kB gz.

Weaknesses

  • The API is complex and the 0.7 β†’ 0.8 migration is non-trivial. Scoring is less transparent than BM25, and the docs are dense.

Best for: large in-browser datasets, and offline-first apps caching their index in IndexedDB.

4.5 Orama

What it is. A TypeScript search engine, formerly "Lyra", that runs in any JS runtime, with an optional hosted cloud.

Strengths

  • The most built in: BM25, typo tolerance, facets, filters, sorting, grouping, geosearch.
  • Vector and hybrid search via plugins, which is rare for a client-side library.
  • Adopted by docs tooling: Fumadocs moved its built-in search from FlexSearch to Orama, and the Node.js docs use it too.

Weaknesses

  • Bigger than MiniSearch or Fuse. Its "< 2 kB" marketing claim doesn't match the measured ~26 kB gz for the v3.1.18 core; third-party trackers report ~22–24 kB.
  • The whole index lives in memory, and vector indexes make it heavier still.
  • Some features point you toward the commercial cloud.

Best for: React/Next.js docs sites, faceted shop-like search, and semantic-search experiments.

4.6 SQLite in the browser

VariantHow it worksRead/WriteBest for
@sqlite.org/sqlite-wasm (official)Maintained by the SQLite projectR/W; persistent via OPFS in a Web WorkerLocal-first apps, editing data in-browser
sql.jsEmscripten build; whole DB in memoryR/W, memory onlySmall DBs downloaded in full
wa-sqlite / SQLocalAlternative builds and wrappers with pluggable VFSesR/WWhen you want more VFS choices, or a friendlier API

Build-time tips

  1. Build the .sqlite file at site-build time with any language (Go, Python, a Hugo data pipeline), run VACUUM, and serve it as a static asset.
  2. Because the whole file downloads, keep it small: drop unused columns, and consider splitting large datasets into per-section files that load on demand.
  3. Use a cache-busting filename when the DB changes.

Caveats

  • The WASM runtime (~335–406 kB gz) is the biggest fixed cost outside DuckDB and PGlite.
  • OPFS persistence needs a Worker. The opfs VFS needs cross-origin isolation (COOP/COEP headers), which many static hosts can't set. The opfs-sahpool VFS avoids that requirement.
  • Bundling the worker plus the .wasm file needs per-bundler configuration.
  • The whole database lives in Worker memory. On mobile, a large DB can push the browser to kill the Worker, which forces a cold restart.

Best for: small-to-medium filterable, sortable catalogs, data journalism, "browse this dataset" pages, and offline-first tools.

4.7 DuckDB-WASM

An in-process analytical (OLAP) SQL engine. It reads Parquet, CSV, JSON and Arrow, either local or over HTTP. For Parquet it fetches the footer first, then only the referenced column chunks.

  • Pros: fast vectorized aggregates over millions of rows, and remote Parquet over Range requests. Great for dashboards.
  • Cons: measured ~7–9 MB gzip runtime, plus 1–3 MB per extension. It runs in memory with high RAM use (~100–300 MB is typical for real workloads), is slower than native, and isn't a search engine.
  • Best for: interactive charts and data exploration, not site search.

4.8 hyparquet

A pure-JS, zero-dependency Parquet reader (~20 kB gz) built to read Parquet over HTTP with Range requests, so a Parquet file on static hosting can be read straight from the browser. It doesn't support SQL. You select columns and row ranges, then filter or aggregate in JS.

  • Use it when you want DuckDB-style "only fetch what you need" reads of a Parquet file without the ~8 MB runtime, for example a table viewer or a chart over a few columns.
  • Gotcha: cross-origin Range requests trigger CORS preflights. Host the file on the same origin, or configure OPTIONS and 206 responses with exposed range headers.

4.9 PGlite

Postgres compiled to WASM by ElectricSQL. Measured ~5.3 MB gz (3.4 MB WASM plus 1.9 MB data file). It supports pgvector, PostGIS and other extensions, and persists to IndexedDB or OPFS.

  • Pros: the same SQL dialect as a server Postgres, and a good fit for local-first sync (ElectricSQL).
  • Cons: heavy, and historically slower than SQLite WASM for simple workloads. Overkill for static search.

4.10 Client-side vector / semantic search

The pattern for static sites: compute document embeddings at build time, ship them as JSON or binary (or cache them in IndexedDB), and embed only the user's query in the browser. Transformers.js running a small model such as all-MiniLM-L6-v2 (384 dimensions) does the embedding.

  • Libraries: Orama with its vector plugin (hybrid BM25 plus vector), client-vector-search (aimed at hundreds to thousands of vectors; HNSW is still on its roadmap), and altor-vec (a ~54 KB WASM vector index; its author reports ~0.6 ms p95 for 10k vectors).
  • Cost: the embedding model download, typically tens of MB, usually dwarfs everything else. Lazy-load it, and consider hybrid search where keyword results show instantly and semantic results load later.

4.11 Others worth knowing

  • staticseek: a lightweight full-text engine supporting all Unicode languages. It's niche but worth a look for CJK-heavy sites that don't use Pagefind.
  • Hosted search (Algolia DocSearch, Typesense, Meilisearch): not static, but the usual escape hatch when you need analytics, synonyms or merchandising, or when the corpus outgrows client-side search. Starlight documents Algolia and Typesense plugins as alternatives to Pagefind.

5. Performance at a glance: a 10,000-page / ~30 MB text site (Rough guideline only)

MetricPagefindMiniSearchFuse.jsSQLite WASMDuckDB-WASMOrama
Runtime download (measured, gz)small JS/WASM, loaded with chunks~6 kB~9 kB~335 kB~7–9 MB~26 kB
Index / data size~100–300 kB fetched, fragmented~2–5 MB JSON index~5–10 MB raw JSON~10–25 MB .sqlite, mostly not fetched~8–15 MB Parquet, mostly not fetched~3–8 MB
Transfer, 1st query~30–80 kBfull indexfull data~5–20 kB~50–200 kBfull index
Transfer, later queries~5–15 kB of new chunks00~1–5 kB~5–10 kB0
Query latency~10–50 ms (network-bound)< 1–5 ms~50–300 ms (linear scan)~5–15 ms after warm-up~10–50 ms< 1–5 ms
Typo toleranceprefix + stemmingedit distanceBitap (best)FTS5 / trigram (DIY)LIKE / regexedit distance
Client RAMvery lowmoderatehighlow–moderatehighmoderate
Build overheadfast Rust crawlJSON export plus prebuilt indexraw JSON exportDB creation and indexingParquet compilationJSON export

Network-profile insight

Pagefind and the Range-request tools (DuckDB-WASM, hyparquet) swap one big download for many small requests. That works well on HTTP/2 or HTTP/3 multiplexing but suffers head-of-line blocking on HTTP/1.1. Every major static host (GitHub Pages, Netlify, Cloudflare Pages, Vercel) serves HTTP/2+, but check self-hosted nginx and Apache setups.


6. Charts

Scores are qualitative (1–10) and drawn from the findings above.

6.1 Setup effort vs. query power

6.2 Measured runtime download (kB, gzip, excluding your data)

Measured runtime size, gzip (Oct 2026), log scale
FlexSearch light
4.5 kB
MiniSearch
6 kB
Fuse.js
9 kB
FlexSearch bundle
18 kB
hyparquet
20 kB
Orama
26 kB
sql.js
335 kB
SQLite WASM
406 kB
PGlite
5.3 MB
DuckDB-WASM
8.1 MB
1 kB10 kB100 kB1 MB10 MB
JS libraryWASM runtime

Each gridline is 10Γ— the one before it. On a normal scale the JS libraries would be invisible next to the WASM databases, which are one to three orders of magnitude heavier. Pagefind is left out because its cost is runtime plus index chunks, ~100–300 kB per search session.

6.3 Capability scores (1–10)

CapabilityPagefindMiniSearchFuse.jsFlexSearchOramaSQLite WASMDuckDB-WASMhyparquet
Ease of setup1081067436
Full-text relevance883797 (FTS5)21
Typo tolerance571068211
Scales to 10k+ pages / large data105276598
Structured queries / filters6435810105
Small payload9101098429
Framework-agnostic108888778
Maintenance health (2026)108107810910

6.4 Decision flow


7. Framework integration

7.1 Compatibility matrix

ToolHugoSvelteKit (adapter-static)Next.js (output: 'export')Astro / Starlight
Pagefind⭐ hugo && pagefind --site public; binary, no Node⭐ run on build/βœ… run on out/; bundler-ignored import⭐ Starlight default; integrations for plain Astro
MiniSearchβœ… JSON custom output format⭐ prerendered +server.js index⭐ build index, loadJSON in client component⭐ static endpoint emits index
Fuse.js⭐ very common (JSON + Fuse)βœ…βœ…βœ…
FlexSearchβœ… several themes ship itβœ…βœ…βœ…
Oramaβœ… community integrationsβœ…β­ first-class in Fumadocsβœ… official Astro plugin
SQLite WASM⚠️ build .sqlite separately, copy to static/βœ… Vite ?worker / ?url⚠️ worker + WASM bundling configβœ… Vite-based, like SvelteKit
DuckDB-WASM / hyparquet⚠️ possible, rarely worth itβœ… dashboardsβœ… dashboardsβœ… islands for dashboards

7.2 Hugo

Hugo is a single Go binary with no JS runtime at build time.

  • Pagefind fits best:
    hugo --minify
    pagefind --site public
    
  • JSON-index libraries: add a JSON output format for the home page and an index.json template that ranges over .Site.RegularPages. Bundle the library with Hugo Pipes (js.Build, which uses esbuild). Fetch the index on search-box focus, not on page load.
  • SQLite: generate the DB in a separate step (Go's modernc.org/sqlite, a Python script, etc.), apply the build tips from Β§4.6 (SQLite in the browser), and drop it into static/.

7.3 Svelte / SvelteKit

  • Use @sveltejs/adapter-static and prerender every route.
  • Hydration isolation: anything touching window or WASM goes inside onMount or a dynamic import(). Otherwise prerendering fails.
  • Pagefind: run it on build/ after vite build, and load /pagefind/pagefind.js with a dynamic import inside onMount.
  • MiniSearch/Orama: emit /search-index.json from a prerendered +server.js endpoint.
  • SQLite/DuckDB: Vite's ?worker and ?url imports make this the smoothest framework for WASM. Wrap the Worker message interface in a Svelte store.

7.4 Next.js (App Router, static export)

  • Set output: 'export' in next.config to get an out/ directory.
  • Search components need 'use client'. Initialize engines in useEffect, because client-side routing doesn't re-run <script> tags.
  • Pagefind: add "postbuild": "pagefind --site out" and load it with import(/* webpackIgnore: true */ '/pagefind/pagefind.js'). Community wrappers such as next-static-search and pagefind-react package this up.
  • WASM: configure webpack/Turbopack to emit .wasm as a static asset, and never evaluate it during prerender.
  • Orama is the path of least resistance for React docs (Fumadocs).
  • With SSR or ISR instead of export, search can live in a route handler, but then the setup is no longer static.

7.5 Astro

  • Starlight ships Pagefind by default with no config. Algolia DocSearch and Typesense are documented plugin alternatives.
  • Plain Astro: use a Pagefind integration, or emit a JSON index from a static endpoint (src/pages/search.json.ts) for MiniSearch, Fuse or Orama, then hydrate a search island with client:idle or client:visible.

8. Recommendations by scenario

ScenarioRecommendationWhy
Blog or docs site, any frameworkPagefindZero code, tiny payload, scales
Hugo site, no Node toolchainPagefind binaryOne command, no npm
Astro/Starlight docsPagefind (built in)Already configured
Next.js docs with a React UIOrama (Fumadocs) or PagefindNative integration vs. framework-free
Catalog of < ~5k structured recordsMiniSearchFull-text, fuzzy and prefix, ~6 kB, prebuilt index
Command palette or title/tag filterFuse.jsBest fuzzy matching on short strings
Large in-browser dataset with offline cacheFlexSearch 0.8Speed plus IndexedDB
Faceted shop-like searchOramaFacets, filters, sorting built in
Semantic or "ask the docs" searchOrama vector plugin + Transformers.js, build-time embeddingsHybrid lexical and semantic, fully client-side
Filter/sort/join a large read-only datasetDuckDB-WASM + ParquetReal SQL, fetches only the columns it needs
Filter/sort/join a small-to-medium datasetOfficial SQLite WASMFull SQLite, one-time download
Offline-first app that edits dataOfficial SQLite WASM + OPFS (or SQLocal)Durable, transactional
Local-first app needing Postgres or pgvectorPGliteSame dialect as the server
Dashboard over Parquet with SQLDuckDB-WASMColumnar analytics
Table or chart from a Parquet file, payload-sensitivehyparquet~20 kB, Range reads, no SQL
Needs analytics, synonyms, merchandisingHosted: Algolia / Typesense / MeilisearchBeyond what static can do

9. Practical gotchas

  1. Measure the index, not just the library. A 6 kB library with a 4 MB index is a 4 MB feature. Only Pagefind, DuckDB/Parquet and hyparquet avoid an all-at-once download.
  2. Prebuild indexes at compile time for MiniSearch, Fuse, FlexSearch and Orama, so users don't spend CPU on indexing.
  3. Lazy-load search. Load the runtime and index on search-box focus, not on every page view.
  4. Range requests need uncompressed, Range-capable hosting. GitHub Pages works. Some CDNs gzip on the fly and break Range requests. Cross-origin Range requests need CORS that exposes Content-Range, Accept-Ranges and Content-Length.
  5. Prefer HTTP/2 or HTTP/3 for the many-small-requests tools (Pagefind, DuckDB-WASM, hyparquet).
  6. CSP: the WASM tools (Pagefind, SQLite, DuckDB, PGlite) may need 'wasm-unsafe-eval'.
  7. COOP/COEP: some SQLite OPFS modes and multithreaded WASM builds need cross-origin isolation headers, which many static hosts can't set. Pick a VFS that doesn't need them (e.g. opfs-sahpool).
  8. Dev vs. production: Pagefind's index exists only after a build, so test with build && preview.
  9. Index staleness: build-tied indexes (Pagefind, prebuilt SQLite or Parquet) must regenerate in CI on every content change. Use cache-busting filenames.
  10. Worker memory: in-memory databases (SQLite, DuckDB, PGlite) can grow large, and mobile browsers may kill Workers. Handle re-initialization gracefully.
  11. Privacy: every client-side option keeps queries on the visitor's device, unlike hosted search.
  12. Re-check sizes and versions before committing. These numbers drift between releases.

More places to find me
Mental Health
follow me on Mastodon