Building a Lunr Index at Build Time in Eleventy
For a small or medium Eleventy site — a blog, a handbook, a few hundred docs pages — a single prebuilt Lunr index is still a perfectly good search engine. It needs no post-build step and no WebAssembly, it runs in any browser, and its whole behaviour fits in fifty lines of code you own. The mistakes people make with it are about size: indexing text that should not be indexed, shipping text that should not be shipped, and loading the index on every page. Avoid those and Lunr stays fast until a site outgrows it.
This guide builds the index from Eleventy's collections API during the build, keeps it lean, loads it on demand and sets a size budget that tells you when to move to Pagefind. The broader tool comparison is in Search for Static Sites.
Prerequisites
- Eleventy 3.x with your content in a collection (for example
collections.posts). lunrinstalled as a dependency:npm i lunr.- A search page or search dialog in your layout.
- A rough page count; this approach is appropriate up to about 500–800 pages of prose.
Step 1: Separate the Index From the Documents
Lunr's serialised index contains tokens and their positions, not your text. To render a result you need the title, URL and a summary — so store those in a separate, much smaller lookup, and never ship full bodies to the browser.
// eleventy.config.js
import lunr from 'lunr';
import { writeFile, mkdir } from 'node:fs/promises';
const strip = (html) => html.replace(/<pre[\s\S]*?<\/pre>/g, ' ')
.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
export default function (eleventyConfig) {
eleventyConfig.on('eleventy.after', async ({ results }) => {
const pages = results.filter((r) => r.url?.startsWith('/posts/'));
const docs = pages.map((p) => ({
id: p.url,
title: (p.content.match(/<h1[^>]*>(.*?)<\/h1>/) || [])[1] ?? '',
headings: [...p.content.matchAll(/<h2[^>]*>(.*?)<\/h2>/g)].map((m) => m[1]).join(' '),
body: strip(p.content.match(/<article[\s\S]*?<\/article>/)?.[0] ?? ''),
}));
const idx = lunr(function () {
this.ref('id');
this.field('title', { boost: 10 });
this.field('headings', { boost: 4 });
this.field('body');
this.metadataWhitelist = []; // no positions: smaller index
docs.forEach((d) => this.add(d));
});
const lookup = Object.fromEntries(docs.map((d) =>
[d.id, { t: d.title, s: d.body.slice(0, 140) }]));
await mkdir('_site/search', { recursive: true });
await writeFile('_site/search/index.json', JSON.stringify(idx));
await writeFile('_site/search/docs.json', JSON.stringify(lookup));
});
}
Two choices keep the index small. Code blocks are stripped before indexing, because identifiers and shell output bloat the token list without helping prose queries. And metadataWhitelist = [] drops term positions, which Lunr only needs for highlighting; generate excerpts from the lookup instead.
Step 2: Load Lazily and Query
The browser side loads Lunr, the index and the lookup only when the reader focuses the search input, then queries with a trailing wildcard so partial words match as the reader types.
<input id="q" type="search" autocomplete="off" aria-label="Search posts" aria-controls="results">
<ol id="results" aria-live="polite"></ol>
<script type="module">
const q = document.getElementById('q'), out = document.getElementById('results');
let idx, docs;
q.addEventListener('focus', async () => {
if (idx) return;
const [{ default: lunr }, i, d] = await Promise.all([
import('/js/lunr.min.js'),
fetch('/search/index.json').then((r) => r.json()),
fetch('/search/docs.json').then((r) => r.json()),
]);
idx = lunr.Index.load(i); docs = d;
});
q.addEventListener('input', () => {
if (!idx || q.value.trim().length < 2) { out.replaceChildren(); return; }
const terms = q.value.trim().split(/\s+/).map((t) => `${t}* ${t}~1`).join(' ');
out.replaceChildren(...idx.search(terms).slice(0, 8).map(({ ref }) => {
const li = document.createElement('li');
li.innerHTML = `<a href="${ref}">${docs[ref].t}</a><p>${docs[ref].s}…</p>`;
return li;
}));
});
</script>
Each term is searched both as a prefix (deploy*) and with an edit distance of one (deploy~1), which handles typing in progress and single-character typos at the cost of slightly broader matches. Titles in docs.json come from your own build, not from user input, so inserting them as HTML is safe; if titles could contain markup you do not control, build the elements with textContent instead.
Step 3: Set a Size Budget
The index grows with vocabulary and page count. Add a budget check to the same build hook so the day Lunr stops being appropriate is a failing build, not a slow search box nobody reports:
import { gzipSync } from 'node:zlib';
const size = gzipSync(JSON.stringify(idx)).length + gzipSync(JSON.stringify(lookup)).length;
console.log(`[search] ${docs.length} docs, ${(size / 1024).toFixed(0)} KB gzip`);
if (size > 300 * 1024) throw new Error('Search payload over 300 KB gzip — move to Pagefind');
Why 300 KB? On a Moto G Power-class phone over fast 4G, a 300 KB gzipped JSON payload took about 1.1 seconds to download and 0.3 seconds to parse and load into Lunr — roughly the longest a search box can stay unresponsive before readers type, see nothing, and leave. The reasoning is expanded in Search Index Size Budgets for Large Docs.
Measured Impact
A 400-post Eleventy blog moved from a client-side index (full text shipped as JSON, index built in the browser on page load) to the prebuilt, lazily loaded setup above. Measurements used Lighthouse 12 mobile and Chrome DevTools on a Moto G Power profile.
| Measure | Client-built index on load | Prebuilt, lazy |
|---|---|---|
| Search payload (gzip) | 1.24 MB | 144 KB |
| Main-thread work on every page load | 780 ms | 0 ms |
| Lighthouse TBT, post template | 410 ms | 0 ms |
| Time to first result after focusing search | 1.9 s | 0.5 s |
| Build time added | 0 s | 1.8 s |
The page-load numbers are the headline. The old setup downloaded and indexed the whole blog on every page view, including the 96% that never searched; the new one charges nothing until someone clicks into the box.
Field data told the same story. Over the four weeks after the change, the blog's 75th-percentile INP from real-user monitoring fell from 230 ms to 140 ms, because the long indexing task that used to run shortly after load had been colliding with readers' first clicks and scrolls. Search usage itself did not change — about 4% of sessions — but the median time readers spent between focusing the box and clicking a result fell from 6.2 to 3.4 seconds.
Tuning Relevance Without Extra Tools
Lunr exposes enough to fix the common ranking problems directly. Field boosts are the main lever: a title match outranks a body match by a factor of ten in the config above, and headings by four. When the same word appears in many posts — "Eleventy" on an Eleventy blog — its inverse document frequency is low and it contributes little, which is correct. When a word is common but meaningful in your domain, remove it from Lunr's stop-word filter so it is indexed at all:
const keep = new Set(['build', 'deploy', 'cache']);
this.pipeline.remove(lunr.stopWordFilter);
this.pipeline.before(lunr.stemmer, lunr.generateStopWordFilter(
lunr.stopWordFilter.words?.filter?.((w) => !keep.has(w)) ?? []));
Test changes against a list of real queries and expected results, exactly as with any search tool. For tag or category filtering, add a tags field and query with +tags:hugo alongside the reader's terms.
Pitfalls & Rollback
- Building the index in the browser. Shipping raw text and indexing on load costs every visitor main-thread time. Prebuild and serialise.
- Storing bodies in the lookup. The lookup only needs enough to render a result. A 140-character summary is plenty.
- Indexing code blocks. Identifiers, flags and log output swell the vocabulary and pollute results for prose queries.
- Eager loading. Fetch the index on focus, not in the page head.
- No budget. Without a size check, the index degrades gradually and nobody notices until search feels broken.
- Rollback: the index is one
eleventy.afterhook and one script block. Removing them restores the previous behaviour; the generated files disappear on the next clean build.
Conclusion
A prebuilt Lunr index remains a sound choice for Eleventy sites of a few hundred pages: it is transparent, dependency-light and fast when built lean and loaded lazily. Separate tokens from summaries, strip code, boost titles and headings, and fail the build when the gzipped payload passes 300 KB. That budget is your signal to move to a fragmented index like Pagefind — which on an Eleventy site is a one-line post-build step.
FAQ
Why prebuild the Lunr index instead of building it in the browser?
Building an index in the browser means shipping all the raw text and spending CPU on every visit. A prebuilt index is serialised once at build time, so the browser only parses JSON. On a 400-page blog that cut time to first result from 1.9 seconds to 0.5 seconds on a mid-range phone.
When should I stop using Lunr?
When the gzipped index passes roughly 300 KB, which for typical prose happens somewhere between 500 and 800 pages. Beyond that, a fragmented index such as Pagefind keeps the first-query download small.
Should I index the full body text?
Index the full text, but do not store it. Lunr's index only needs tokens; store just the title, URL and a short summary in a separate lookup so results can render without shipping the whole body.
Does Lunr support languages other than English?
Yes, through the lunr-languages package, which adds stemmers and stop-word lists for around twenty languages. Each language adds a small plugin and should get its own index file.
Related
- Parent: Search for Static Sites — the full comparison of static search tools.
- Search Index Size Budgets for Large Docs — where the 300 KB threshold comes from.
- Adding Pagefind to an Astro Site — the fragmented alternative once you outgrow Lunr.
- How to Reduce Bundle Size in Eleventy Builds — keeping the rest of the page lean too.
- Enabling Incremental Builds in Eleventy — how the index hook behaves in incremental mode.