Search
A hand-rolled, dependency-free client-side search: a build-time JSON index plus a small scored-match script.
Live Implementation
Try it — this is the same widget from the header, reused here for convenience. Start typing a demo title, tag, or word from its description:
How It Works
Search here has two halves, and both are plain to read, no external search service or WASM binary involved:
- Build time.
search-index.njkloops overcollections.demosandcollections.postsand turns each item into a small object:{ title, url, description, tags }. Eleventy writes that out as static JSON at/search-index.json, fresh on every build. - Runtime.
search.jsfetches that JSON once when the page loads, then scores every entry on each keystroke by counting how many of your search words show up in its title, description, or tags (title matches count for more). It sorts by score and drops the top results into a list under the input.
This is progressive enhancement. The input works fine with JavaScript off, you just get a plain text field. With it on, you get instant results with no network request per keystroke, since the whole index is already sitting in memory.
Folder Structure
src/search-index.njk ← outputs /search-index.json
src/assets/js/search.js ← fetch + scored match + render
src/_includes/components/search-bar.njk ← the + results list Important Files
src/search-index.njk
---
permalink: /search-index.json
eleventyExcludeFromCollections: true
---
{%- set allItems = collections.demos.concat(collections.posts) -%}
[
{%- for item in allItems -%}
{
"title": {{ item.data.title | dump | safe }},
"url": {{ item.url | dump | safe }},
"description": {{ item.data.description | dump | safe }},
"tags": {{ item.data.tags | dump | safe }}
}{% if not loop.last %},{% endif %}
{%- endfor -%}
] src/assets/js/search.js
const searchRoots = document.querySelectorAll("[data-search]");
if (searchRoots.length) {
let index = null;
async function loadIndex() {
if (index) return index;
const response = await fetch("/search-index.json");
index = await response.json();
return index;
}
function scoreItem(item, tokens) {
const title = item.title.toLowerCase();
const description = item.description.toLowerCase();
const tags = item.tags.join(" ").toLowerCase();
let score = 0;
for (const token of tokens) {
if (title.includes(token)) score += 3;
if (tags.includes(token)) score += 2;
if (description.includes(token)) score += 1;
}
return score;
}
function renderResults(resultsList, items) {
resultsList.innerHTML = "";
if (!items.length) {
resultsList.innerHTML = '<li class="search__empty">No matches.</li>';
resultsList.hidden = false;
return;
}
for (const item of items) {
const li = document.createElement("li");
li.className = "search-result";
li.innerHTML = `<a href="${item.url}">${item.title}</a><p>${item.description}</p>`;
resultsList.appendChild(li);
}
resultsList.hidden = false;
}
searchRoots.forEach((searchRoot) => {
const input = searchRoot.querySelector("[data-search-input]");
const resultsList = searchRoot.querySelector("[data-search-results]");
input.addEventListener("input", async () => {
const query = input.value.trim().toLowerCase();
if (!query) {
resultsList.hidden = true;
resultsList.innerHTML = "";
return;
}
const items = await loadIndex();
const tokens = query.split(/\s+/).filter(Boolean);
const matches = items
.map((item) => ({ item, score: scoreItem(item, tokens) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 8)
.map((entry) => entry.item);
renderResults(resultsList, matches);
});
document.addEventListener("click", (event) => {
if (!searchRoot.contains(event.target)) {
resultsList.hidden = true;
}
});
});
} Notes
- No dependencies here: no Lunr, Fuse.js, Pagefind, or server, just
fetchand array scoring. - The index only holds title/description/tags, not full page text, to keep the JSON small. At a much bigger scale you’d want a real inverted index, which is exactly what the Pagefind demo covers.
- The index gets rebuilt every time, so it can never go stale.