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).
  • lunr installed 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.

Two files instead of one Eleventy's rendered pages feed an eleventy.after hook. The hook writes index.json, containing only tokens with field boosts for title, headings and body, and docs.json, containing only title and a 140-character summary per page. The browser needs both to render results but never downloads full page bodies. Index tokens, look up summaries, never ship bodies rendered pages 400 posts eleventy.after strip code, boost fields search/index.json tokens only · 118 KB gzip search/docs.json title + 140-char summary · 26 KB gzip Full bodies would have added 1.1 MB gzip; the reader never needs them to pick a result
Splitting the search payload keeps the download proportional to the vocabulary, not to the total amount of prose.

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.

Search payload size as the blog grows A line chart of gzipped search payload against number of posts. The lean index with code stripped and no positions grows from 40 kilobytes at 100 posts to 144 at 400 and crosses the 300 kilobyte budget at about 820 posts. A naive index that stores full bodies and positions reaches 300 kilobytes at about 130 posts. Gzipped search payload vs number of posts 300 KB budget 0 300 450 100 300 500 700 900 naive: full bodies + positions lean: tokens + summaries Measured on a technical blog, ~1,100 words per post; x axis is number of posts
The lean layout buys roughly six times more headroom before the budget trips — enough for most blogs to never need a different tool.

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.

MeasureClient-built index on loadPrebuilt, lazy
Search payload (gzip)1.24 MB144 KB
Main-thread work on every page load780 ms0 ms
Lighthouse TBT, post template410 ms0 ms
Time to first result after focusing search1.9 s0.5 s
Build time added0 s1.8 s
Main-thread cost per page view, before and after Two main-thread timelines for a post page. Before, the page spent 780 milliseconds downloading and indexing 1.24 megabytes of text on load, producing 410 milliseconds of blocking time. After, page load has no search work at all, and a 0.5 second load happens only if the reader focuses the search box. Main thread during a post page load Before render build Lunr index · 780 ms After render done — no search work only on focus · 0.5 s Chrome DevTools performance panel, Moto G Power profile, 4× CPU slowdown
The expensive work did not get faster so much as it stopped happening for readers who never search.

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.after hook 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.