lunr
Lunr is an in-memory full-text search engine for JavaScript. It builds an inverted index from JSON documents, ranks matches with BM25, and supports field scoping, boosts, wildcards, fuzzy terms, and required or prohibited terms without a server. It fits static documentation, offline apps, and modest client-side collections where search must keep working without a network. It returns document references and scores, not stored records or a hosted search service.
Lunr is still a good boring choice for prebuilt, offline search over a modest static corpus. New applications needing live updates, modern packaging, or advanced product-search features should start with MiniSearch, FlexSearch, or a server-backed engine.
Use it if
- A mostly static document collection can be indexed at build time and searched entirely in the browser
- Your application must provide useful offline full-text search without a server
- You need field boosts, BM25 ranking, fuzzy terms, and query syntax in a compact dependency-free package
- You can keep the original records separately and join Lunr result references back to them
- Your dataset changes continuously: a built `Index` is effectively immutable, so updates mean rebuilding or replacing the index
- The collection is large enough that building or loading the full index blocks the browser or consumes uncomfortable memory; the official guide warns large builds can make the page unresponsive
- You need first-class non-English search without plugins: the language guide says core Lunr fully supports English and sends other languages to `lunr-languages`
- You need a current ESM and TypeScript package: version 2.3.9 exposes a CommonJS `main`, declares no module entry or bundled types, and was released in August 2020
- You need typo-tolerant product search with facets, filters, analytics, synonyms, and live indexing; Lunr has text-query primitives but not a search-service feature set
Setup reality
`npm install lunr` adds no runtime dependencies and costs about 8.3 KB gzipped, but the package is old-style JavaScript: 2.3.9 declares `lunr.js` as its CommonJS main and ships no `module` or TypeScript declaration entry. Bundler interop and separate community types may be needed in strict ESM or TypeScript projects. At runtime, define the reference field and every searchable field before adding documents. Lunr stores its index, match metadata, and document references, not your original records, so keep a map or database for rendering results. Building a large index on page load can block the main thread; the official guide recommends building it in Node, serializing with `JSON.stringify`, compressing it, and loading it with `lunr.Index.load`. That trades build time for download size and memory, so measure both. Search strings are a query language: colons, carets, tildes, wildcards, plus, and minus change meaning, and an unrecognized field throws. Multiple bare terms use OR, not AND. Leading wildcards are documented as expensive, and wildcard terms skip stemming. English stemming and stop words are built in; other languages require `lunr-languages` loaded in the correct plugin order. Rebuild and redeploy the index whenever documents or pipeline configuration change.
Patterns
Build an index from JSON documentsbuild-index
const lunr = require('lunr');
const documents = [
{ id: '1', title: 'Moonlight', body: 'Light reflected by the moon' },
{ id: '2', title: 'Sunrise', body: 'The sun appears above the horizon' },
];
const index = lunr(function () {
this.ref('id');
this.field('title');
this.field('body');
documents.forEach((document) => this.add(document));
});The reference must be unique and every searchable field must be registered before documents are added.
Join result references back to recordsresolve-search-results
const byId = new Map(documents.map((document) => [document.id, document]));
const results = index.search('moon');
const matches = results.map(({ ref, score }) => ({
document: byId.get(ref),
score,
}));Lunr returns references, scores, and match metadata; it does not store or return your original document objects.
Give title matches more weightboost-index-fields
const index = lunr(function () {
this.ref('id');
this.field('title', { boost: 10 });
this.field('body');
documents.forEach((document) => this.add(document));
});Index-time field boosts affect every query; use query-time boosts when importance depends on the search screen.
Restrict a term to one fieldsearch-specific-field
const results = index.search('title:moon');The field name must have been registered by the builder; an unknown field causes a query error.
Search by term prefixsearch-prefix
const results = index.search('astro*');Wildcard terms are not stemmed, and leading wildcards such as `*astro` can be slow on large indexes.
Allow one edit of fuzzinesssearch-fuzzy-term
const results = index.search('javascript~1');Larger edit distances broaden results and cost more; keep fuzziness low for short terms.
Express AND and NOT term presencerequire-and-exclude-terms
const results = index.search('+javascript +testing -browser');Bare multi-term searches use OR; prefix every required term with `+` to simulate AND.
Boost an important query termboost-query-term
const results = index.search('javascript^10 testing');The caret accepts a positive integer and changes ranking, not whether the other optional term may match.
Build a query without string syntaxbuild-programmatic-query
const results = index.query((query) => {
query.term('javascript', {
fields: ['title'],
boost: 5,
presence: lunr.Query.presence.REQUIRED,
});
query.term('legacy', { presence: lunr.Query.presence.PROHIBITED });
});The programmatic API avoids treating user punctuation as query syntax and gives direct control over fields and presence.
Serialize an index during a buildserialize-index
const fs = require('node:fs');
fs.writeFileSync('public/search-index.json', JSON.stringify(index));Store the source records separately and rebuild this artifact whenever documents, fields, boosts, or pipeline functions change.
Load a prebuilt index in the browserload-prebuilt-index
import lunr from 'lunr';
const serialized = await fetch('/search-index.json').then((response) => response.json());
const index = lunr.Index.load(serialized);
const results = index.search('offline');Loading is faster than building, but the serialized index still adds download and in-memory cost to the page.
Enable the French language pipelineindex-french-text
const lunr = require('lunr');
require('lunr-languages/lunr.stemmer.support')(lunr);
require('lunr-languages/lunr.fr')(lunr);
const index = lunr(function () {
this.use(lunr.fr);
this.ref('id');
this.field('text');
documents.forEach((document) => this.add(document));
});Core Lunr fully supports English; load stemmer support before the chosen `lunr-languages` plugin.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| minisearch | npm | You want a maintained client-side engine with incremental add, remove, autocomplete, and fuzzy search |
| flexsearch | npm | Raw client-side indexing and lookup speed matter more than a small, simple API |
| fuse.js | npm | You need fuzzy matching over a modest array without building a full-text index |
| elasticlunr | npm | You want a Lunr-like API with document updates and removals |