FlexSearch in this project
This site uses FlexSearch's Document index to provide client-side, full-text search across all blog posts, with no server and no build-time bundler required. Everything runs as a native ES module in the browser.
There are three moving parts:
.eleventy.js— ships the FlexSearch browser bundle as a static asset.src/search-index.njk— generates a JSON search index fromcollections.postsat build time.src/_includes/header.njk— loads the index into a FlexSearchDocumentin the browser and renders a search modal (a native<dialog>) wired up to it.
Because header.njk is included by base.njk, this modal and its script are present on every page.
1. Shipping FlexSearch as a static asset
// .eleventy.js
eleventyConfig.addPassthroughCopy({
"node_modules/flexsearch/dist/flexsearch.bundle.module.min.mjs": "assets/flexsearch.bundle.module.min.js",
});
flexsearch is an npm dependency (package.json), but it's never imported into any Node build step here.
Instead, Eleventy just copies FlexSearch's pre-built ESM bundle straight into _site/assets/ so the
browser can import it directly:
<script type="module">
import { Document } from "/assets/flexsearch.bundle.module.min.js";
</script>
The file is renamed from .mjs to .js on copy only so static file servers reliably serve it with a
JavaScript MIME type — the contents (an ES module with export statements) are unchanged.
This bundle includes FlexSearch's Index, Document, Charset, Encoder, Worker, Resolver, and
IndexedDB exports. This project only uses Document.
2. Building the search index (src/search-index.njk)
---
permalink: /search-index.json
eleventyExcludeFromCollections: true
---
[
{%- for post in collections.posts -%}
{%- if not loop.first -%},{%- endif -%}
{
"id": {{ loop.index0 | dump | safe }},
"title": {{ post.data.title | dump | safe }},
"url": {{ post.url | dump | safe }},
"tags": {{ post.data.tags | dump | safe }},
"content": {{ post.templateContent | striptags | trim | dump | safe }}
}
{%- endfor -%}
]
This is a Nunjucks template whose output is JSON, not HTML — Eleventy writes it to /search-index.json
in the built site. A few things worth calling out:
eleventyExcludeFromCollections: truekeeps this generated file out ofcollections.all/collections.posts, so it doesn't accidentally show up as a "post" anywhere.post.templateContentis the post's fully rendered HTML (Eleventy computes this for every collection item — it's the same mechanism RSS feed templates use).striptags(a built-in Nunjucks filter) strips the HTML tags from that rendered content, leaving plain text suitable for indexing.dump | safeis doing the JSON-escaping. Nunjucks'dumpfilter isJSON.stringifyunder the hood, so{{ post.data.title | dump | safe }}renders a correctly quoted-and-escaped JSON string (or array, fortags) directly into the output — this is what makes it safe to hand-roll JSON with string concatenation in a template instead of needing a real JSON serializer.
The result looks like:
[
{
"id": 0,
"title": "Using Svelte with Eleventy",
"url": "/posts/using-svelte-with-11ty/",
"tags": ["posts", "svelte", "11ty"],
"content": "Combine Eleventy's speed with Svelte 5's modern reactivity by precompiling..."
}
]
This file is rebuilt on every Eleventy build, so it's always in sync with src/posts/*.md.
3. Building the index in the browser (src/_includes/header.njk)
3.1 Configuring the Document index
const index = new Document({
document: {
id: "id",
store: ["title", "url"],
index: ["title", "content", "tags"],
},
tokenize: "forward",
});
FlexSearch has two index types: Index (search over a single flat string per entry) and Document
(search over multiple named fields of structured objects, this project's case).
document.id— which field of each added object is its unique identifier.document.index— which fields get tokenized and searched (title,content,tags). Each field gets its own internal index, so a query can match on title text, body text, or tag names independently.document.store— which fields are kept, verbatim, alongside the index so they can be retrieved in results without a second lookup (titleandurl, everything the results list needs to render a link).contentis deliberately not stored — it's only there to be searchable, not to be displayed, so storing it would just waste memory.tokenize: "forward"controls how strings are split into searchable tokens."forward"indexes every forward-growing substring of each word ("search"→s,se,sea,sear,searc,search), which enables prefix matching — so typing"sve"matches"svelte"as the user is still typing it, which is what you want for an as-you-type search box like this one.
3.2 Populating the index
const posts = await fetch("/search-index.json").then((res) => res.json());
for (const post of posts) {
index.add({
id: post.id,
title: post.title,
content: post.content,
tags: (post.tags || []).join(" "),
url: post.url,
});
}
On every page load, the browser fetches the generated /search-index.json and adds every post to a
fresh, in-memory FlexSearch index. There's no persistence (no IndexedDB, no caching) — the index is
rebuilt from scratch each time the page loads. For a handful of posts this is effectively instant; if the
post count grows into the thousands, this is the first place to revisit (see Scaling, below).
Note tags is joined into a single space-separated string before being added — FlexSearch indexes each
field as text, so an array needs to be flattened to a string first.
3.3 Querying the index
input.addEventListener("input", () => {
const query = input.value.trim();
results.innerHTML = "";
if (!query) return;
const matchesById = new Map();
for (const fieldResult of index.search(query, { enrich: true, limit: 10 })) {
for (const match of fieldResult.result) {
matchesById.set(match.id, match.doc);
}
}
for (const doc of matchesById.values()) {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = doc.url;
a.textContent = doc.title;
li.appendChild(a);
results.appendChild(li);
}
});
This runs on every keystroke in the search input (no debounce — the index is small and in-memory, so this is cheap).
-
index.search(query, { enrich: true, limit: 10 })searches all indexed fields (title,content,tags) and returns one result set per field, e.g.:[ { field: "title", result: [{ id: 3, doc: {...} }] }, { field: "content", result: [{ id: 3, doc: {...} }, { id: 1, doc: {...} }] }, { field: "tags", result: [] }, ]Without
enrich: true, eachresultentry would just be an id;enrich: truetells FlexSearch to look up the stored fields (title,url) and attach them as.docon each match. -
The
Mapkeyed bymatch.idflattens those per-field result sets into a single deduplicated list — a post that matches on both its title and its content should only appear once in the results. -
limit: 10caps each field's result count at 10 (not the combined total) — with only a few posts in this site that ceiling is never reached, but it's worth knowing it isn't a global cap on the merged list.
4. The search modal
The search UI is a native <dialog> element (styled by Pico CSS's built-in modal pattern), not a
separate page:
<button type="button" id="search-open">Search</button>
<dialog id="search-dialog">
<article>
<header>
<button aria-label="Close" rel="prev" id="search-close"></button>
<p>Search</p>
</header>
<input type="search" id="search-input" placeholder="Search posts...">
<ul id="search-results"></ul>
</article>
</dialog>
openButton.addEventListener("click", () => {
dialog.showModal();
input.focus();
});
closeButton.addEventListener("click", () => dialog.close());
dialog.addEventListener("click", (event) => {
if (event.target === dialog) dialog.close();
});
dialog.addEventListener("close", clearSearch);
dialog.showModal()opens it as a true modal (focus-trapped, with a backdrop, and — for free — closes on Escape without any code of ours).- Backdrop click to close works by checking
event.target === dialog: clicks inside the<article>don't bubble up with the dialog itself as the target, only clicks in the dialog's own padding/backdrop area do. - Every dismissal path — the X button, backdrop click, or Escape — ends by firing the dialog's native
closeevent, which is the single placeclearSearch()resets the input and results. This means there's one code path for "the modal just closed," not three.
Maintenance notes
- Adding/editing posts: nothing to do —
search-index.njkregenerates fromcollections.postson every build. - Indexing another field (e.g.
description): add it todocument.indexin theDocumentconfig, add it to the object passed toindex.add(...), and add it to the JSON emitted bysearch-index.njk. - Scaling beyond a small blog: if the post count or content size grows significantly, consider:
- Building the FlexSearch index once at build time and serializing it (
index.export(...)) instead of re-indexing raw JSON on every page load. - Debouncing the
inputlistener. - Using FlexSearch's
Workerexport to move indexing/search off the main thread.
- Building the FlexSearch index once at build time and serializing it (