I Couldn't Decide Which Library to Use, So I Wrote My Own Website Search
January 2025
I needed a search function on my website without bloating it with heavy libraries or server-side tools. Instead of relying on frameworks, I decided to write my own lightweight, client-side search from scratch. Here's how I did it, and why building it myself turned out to be a good idea.
Finally, the spiffy new search function on marincomics.com.
During the holidays between Christmas and New Year's eve 2024/25, I had planned to attend the 38C3 Chaos Communication Congress in Hamburg. Unfortunately, the high demand for tickets meant I couldn't secure one. As a last-ditch effort, I submitted a drawing for their call for art, hoping my poster Coding Counter Culture might earn me a spot. Sadly, it wasn't accepted.
With Plan A off the table, I turned to Plan B: updating my website. I set out to refresh the header with more current articles, revamp the navigation bar, and—most ambitiously—implement a full-text site search function. After some trial and error, I managed to make it work. I call it Lazerback, the client-side website search for static websites. This article is a rundown of how I built my own client-side search, step by step.
Hand-crafting A Labor of Enthusiasm
I've always been fascinated with creating a fully hand-crafted website. Here on marincomics.com I built my digital space to showcase my side projects, from articles and research to comics, videos, and more. My approach has been simple: no libraries except Bootstrap 5 for layout and styling (and even that feels like a concession). Over time, my static website grew into a sprawling collection of hand-coded HTML, CSS, and a sprinkle of JavaScript.
This approach worked beautifully at first. I was experimenting with content types: single-page articles, multi-page deep dives with side-navigation, and curated category collections displaying thumbnails, titles, and intro ledes. Every page was unique and lovingly crafted. But as the site grew, maintaining it became tedious. Repetition crept in. Adding a new article felt more like choreographed drudgery than creative work.
The Case for a Custom Static Site Generator
Like many developers, I turned to static site generators. Jekyll, Eleventy, MkDocs, Pandoc, and even the slick Astro framework all crossed my radar. But every option felt like overkill. I didn't need a full ecosystem—just a tool to transform my Markdown articles into HTML and slot them into predefined layouts. Titles, ledes, images, and metadata should be seamlessly integrated. TOCs and navigation for multi-page articles? Check. Copying assets and updating references? Also check.
Frustrated, I decided to build my own static site generator using Node.js and JavaScript. It wasn't pretty (inspired by Douglas Adams' Agrajag, I named it after the endlessly reincarnated creature), but it worked. Each rewrite taught me something new. Maybe someday I'll clean it up for public release, though its functionality is so niche, I doubt anyone else would find it useful.
Eventually, I streamlined my publishing workflow. Initially, I manually uploaded files using SFTP. Later, I implemented a GitHub Workflow to automate deployment. It's not perfect—I still haven't set up a branch-based system—but it's functional.
The Search Problem
As my site surpassed 100 pages, one glaring limitation became evident: there was no search functionality. I'd lived without it for a while, but it became a clear need as content grew.
I started exploring JavaScript libraries for client-side search. My requirements were simple:
- Lightweight and fast.
- Client-side, as the site is static.
- Return relevant results in an intuitive format.
I evaluated Lunr.js and FlexSearch. Both are impressive in their own right, but they had limitations that didn't align with my vision.
Why Existing Libraries Didn't Work for Me
Both libraries required indexing the entire site into a JSON file containing titles, URLs, and page content. With over 100 pages, my index file was over 2 MB. While caching mitigates repeated downloads, the initial load time felt excessive for visitors, especially on a site with HTML5 animations, high-res images, and other bandwidth-heavy content.
Lunr.js was the first library I tried. Using Cheerio, I crawled my site to generate an index. The resulting JSON file had issues: line breaks, empty spaces, and inconsistencies in older pages. Cleaning it up was a good opportunity to standardize metadata, but the search results disappointed me. Lunr returned article titles and URLs but didn't provide deeper functionality like jumping directly to the search term within the article or highlighting it.
FlexSearch had a smaller footprint and was slightly faster, but its functionality was similar. Both libraries left me needing to implement core features myself.
In the end, I hesitated to add another dependency to my site for something I could build myself.
Building My Own Search Solution
Stepping back, I realized I didn't need a library. I already knew how to generate a site index using Cheerio. I needed to define the exact features I wanted and build them myself.
1. Create a Search Index
The process begins by creating a search index from the locally stored static website using the Cheerio library. I wrote a Node.js script for this purpose. This is the only part of the project that requires Node.js, as it uses the Cheerio and FS libraries. The script processes a list of local URLs, extracting the title, pathname, and all text within the <main> element. It then cleans up unnecessary line breaks and multiple spaces before appending the processed data to a search index. The resulting search index is saved as a separate JavaScript file, exported as a constant.
2. Provide the UI Elements for the Search
The website requires a search field and a section to display the results. These elements are dynamically created and inserted into the page when the search is triggered. This functionality is implemented in search.js, which is included on every page. While the example utilizes Bootstrap 5 for styling, it's not mandatory. The modal and the search field within it use Bootstrap 5-specific styles, making them visually cohesive with the rest of the site.
3. Implement the Search Function
The search function itself is straightforward. It imports the constant containing the search index and processes the search term entered by the user. To ensure case-insensitive functionality, the function normalizes the case of both the search term and the indexed data before matching.
4. Display the Search Results
If matches are found, the function displays the search results in the designated area of the page. Each result is shown as a linked title, allowing users to click through to the corresponding article. The URLs include a query parameter to carry the search term for further processing.
5. Jump to the Right Section and Highlight the Search Term
When a page is loaded with a search parameter in the URL, a script identifies the first occurrence of the search term on the page. It wraps the term in a <mark> tag for highlighting, waits briefly (1000ms) to ensure the marked HTML is fully rendered, and then scrolls to the highlighted section. This enhances usability by providing direct visual feedback and easy navigation to the relevant content. And most of all, I wanted to understand how my search works.
Implementation
Then I went ahead and built the following search function. It's lightweight, fast, and tailored to my site's needs. The search function is client-side, meaning it doesn't rely on external libraries or server-side tools. You can find the Lazerback project here on GitHub: Lazerback Search.
Script to Create a Search Index for a Static Website
This code creates a search index for a set of HTML pages and saves it as a JavaScript file for use in a client-side search.
This code creates a search index for a set of HTML pages and saves it as a JavaScript file for use in a client-side search.
const fs = require('fs');
const cheerio = require('cheerio');
const pages = ["example-01.html",
"example-02.html",
"example-03.html"]
const results = [];
pages.forEach(page => {
const content = fs.readFileSync(page, "utf8");
const $ = cheerio.load(content);
$("*").contents().filter((index, node) => node.type === "text").each((index, node) => {
node.data = node.data.replace(/[\n\t]/g, "");
});
$("*").contents().filter((index, node) => node.type === "text").each((index, node) => {
node.data = node.data.replace(/\s{2,}/g, " ");
});
results.push({
id: page,
title: $("title").text(),
content: $("main").text(),
url: `/${page}`
});
});
indexdata = results;
fs.writeFileSync("siteindex.js", `export const indexdata = ${JSON.stringify(results, null, 2)};`);
Here's a breakdown of what it does:
Modules and Variables: The fs module is used to read and write files. The Cheerio library is used to parse and manipulate HTML content. A list of HTML files (pages) is specified, and an empty array (results) is initialized to store the processed data.
Processing Each Page: For each file in the pages list the file is read as a UTF-8 string using fs.readFileSync and the content is loaded into Cheerio for parsing.
Cleaning Text Nodes: All text nodes in the HTML are filtered and cleaned by having line breaks (\n) and tabs (\t) are removed and excess whitespace reduced to a single space.
Extracting Data: The script extracts the following for each page:
- ID: The file name (e.g., example-01.html).
- Title: The content of the <title> element.
- Content: The text inside the <main> element, representing the main content of the page.
- URL: A relative URL constructed from the file name.
Storing Results: The extracted data is added to the results array as an object for each page.
Saving the Index: The results array is written to a file called siteindex.js as an exported JavaScript constant (indexdata). The file is formatted with indentation for readability.
This code effectively transforms the content of the specified HTML pages into a structured index, making it ready for use in the search.
Structure of indexdata
This is an example of the indexdata constant created by the script using text from three example pages taken from the Project Gutenberg eBook of English Fairy Tales compiled by Joseph Jacobs.
export const indexdata = [
{
"id": "example1.html",
"title": "Tom Tit Tot - Example 1",
"content": "Tom Tit Tot Once upon a time there was a woman, and she baked five pies...",
"url": "/example1.html"
},
{
"id": "example2.html",
"title": "The Three Sillies - Example 2",
"content": "The Three Sillies Once upon a time there was a farmer and his wife who had...",
"url": "/example2.html"
},
{
"id": "example3.html",
"title": "The Rose-Tree - Example 3",
"content": " The Rose-Tree There was once upon a time a good man who had two children...",
"url": "/example3.html"
}
];
Let's take a closer look at the structure of the indexdata constant. The indexdata is an array where each element represents a single webpage or content item. Each object contains the following fields:
-
id: A unique identifier for the webpage.
Example: "example1.html". -
title: The title of the webpage, typically used
for display in search results.
Example: "Tom Tit Tot - Example 1". - content: The full text content of the webpage. The content is stored as a string, which can be indexed and searched.
-
url: The relative URL where the content can be
accessed.
Example: "/example1.html".
The structure is designed to enable efficient client-side searching. It achieves this by providing all searchable text within the content field, ensuring that relevant information can be easily indexed. Additionally, it includes metadata such as titles and URLs, which are essential for displaying clear and accessible search results. To simplify access, the data is stored as a JavaScript constant (indexdata), eliminating the need for JSON parsing and making the search implementation more straightforward.
Search Input Field and Search Result Area
Let's go ahead and build the search UI elements.
function generateSearchModal() {
const modal = document.createElement("div");
modal.className = "modal fade";
modal.id = "searchModal";
modal.tabIndex = -1;
modal.setAttribute("aria-labelledby", "searchModalLabel");
modal.setAttribute("aria-hidden", "true");
const modalDialog = document.createElement("div");
modalDialog.className = "modal-dialog modal-dialog-scrollable";
const modalContent = document.createElement("div");
modalContent.className = "modal-content";
const modalBody = document.createElement("div");
modalBody.className = "modal-body";
const searchLabel = document.createElement("label");
searchLabel.setAttribute("for", "searchInput");
searchLabel.className = "fs-6";
searchLabel.textContent = "Type your search term and press 'Return':";
const inputGroup = document.createElement("div");
inputGroup.className = "input-group mb-3";
const searchInput = document.createElement("input");
searchInput.type = "text";
searchInput.className = "form-control";
searchInput.id = "searchInput";
searchInput.placeholder = "What would you like to find?";
const searchButton = document.createElement("button");
searchButton.className = "btn btn-outline-secondary";
searchButton.type = "button";
searchButton.textContent = "Search";
searchButton.onclick = function () {
searchInText();
};
inputGroup.appendChild(searchInput);
inputGroup.appendChild(searchButton);
const searchResults = document.createElement("ul");
searchResults.id = "searchResults";
searchResults.className = "list-group text-start";
modalBody.appendChild(searchLabel);
modalBody.appendChild(inputGroup);
modalBody.appendChild(searchResults);
modalContent.appendChild(modalBody);
modalDialog.appendChild(modalContent);
modal.appendChild(modalDialog);
if (document.body) {
document.body.appendChild(modal);
}
}
This code dynamically creates and inserts a Bootstrap 5 modal for search functionality into the webpage. Here's what it does:
Create the Modal Structure: A <div> element is created for the modal, with appropriate Bootstrap classes (modal fade) and attributes (id, tabindex, aria-labelledby, aria-hidden).
Build the Modal Components: A modal-dialog div with scrollable functionality is created. A modal-content div and its modal-body are added to house the modal's contents.
Add Search Input and Label: A label prompts users to enter a search term. An input field (searchInput) with placeholder text is created for typing the search term. A search button triggers a searchInText() function when clicked.
Display Search Results: An unordered list (searchResults) is created to display search results as a styled Bootstrap list group.
Assemble and Append: The label, input group (containing the input field and button), and results list are appended to the modal-body. The modal-body, modal-content, and modal-dialog are sequentially assembled into the modal. Finally, the modal is appended to the document's <body>.
This function ensures the modal is fully configured with Bootstrap 5 styling and ready for user interaction when inserted into the DOM of each HTML file of my website.
Triggering the Search Only When Necessary
I didn't want to load the site index and display the search modal until the user actually wanted to search. Here's how I handled it.
function triggerSearch() {
import("./siteindex.js").then((module) => {
indexdata = module.indexdata;
});
const searchModal = new bootstrap.Modal(
document.getElementById("searchModal")
);
inputElement.value = "";
resultsElement.innerHTML = "";
searchModal.show();
searchModal._element.addEventListener("shown.bs.modal", () => {
inputElement.focus();
});
}
This code defines a function triggerSearch
that
handles the initialization and display of a search modal, while
ensuring that the site index is dynamically loaded only when
needed. Here's what it does.
Dynamically Load the Site Index: It uses dynamic
import()
to load the
siteindex.js
module, which contains the large indexed
text data. This ensures the index is not loaded unless the search
is triggered.
Initialize the Bootstrap Modal: A Bootstrap modal
instance is created for the search modal
(searchModal
).
Clear Previous Input and Results: The search
input field (inputElement
) is cleared. The search
results area (resultsElement
) is emptied to ensure a
fresh state for the new search.
Show the Modal: The search modal is displayed
using searchModal.show()
.
Focus on the Search Input: Once the modal is
fully displayed (shown.bs.modal
event), the search
input field gains focus to allow the user to start typing
immediately.
This approach optimizes performance by loading the large site index only when necessary and enhances user experience by automatically focusing the input field.
The Actual Search Function
The search function is the core of the client-side search. It processes the search term entered by the user, matches it against the indexed data, and displays the results. Here's how it's implemented:
function searchInText() {
searchTerm = inputElement.value;
let searchResults = indexdata.filter((item) => {
return item.content.toLowerCase().includes(searchTerm.toLowerCase());
});
resultsElement.innerHTML = "";
displaySearchResults(searchResults);
}
The searchInText
function performs a search based on
the user's input. It retrieves the search term from the input
field, filters the indexdata
array to find entries
where the content
field contains the search term
(case-insensitive), and then clears the previous results before
displaying the new search results using the
displaySearchResults
function.
Displaying the Search Results in the Search Modal
The search results are displayed in the search modal as a list of clickable titles.
function displaySearchResults(searchResults) {
if (searchResults.length === 0) {
let noResults = document.createElement("li");
noResults.classList.add("list-group-item");
noResults.textContent = "No results found.";
resultsElement.appendChild(noResults);
return;
}
let urlEncodedSearchTerm = encodeURIComponent(searchTerm);
searchResults.forEach((item) => {
let itemAnchor = document.createElement("a");
itemAnchor.classList.add("list-group-item");
itemAnchor.href = `${item.url}?search=${urlEncodedSearchTerm}`;
itemAnchor.textContent = item.title;
resultsElement.appendChild(itemAnchor);
});
}
The displaySearchResults
function is responsible for
dynamically displaying the search results in a list format. It
operates as follows:
Handle No Results: If no matching results are
found, the function creates a new list item (<li>) with the
message "No results found." This item is styled using the
list-group-item
class and appended to the designated
results
container. The function then exits.
Display Results: For each search result, the
function creates a clickable link (<a> element), styled as a
list item with the list-group-item
class. The link's
href
attribute is set to the result's URL, including
the search term as a query parameter for context. The link's text
content is set to the title of the result. Finally, the link is
appended to the results
container.
This ensures that users see a clear and well-organized list of search results, or an appropriate message if no results are found.
Landing On the Right Section and Highlighting the Search Term
When a user clicks on a search result, the page is loaded with the search term highlighted and scrolled to the relevant section.
let search = window.location.search;
let main = "";
if (document.querySelector("main")) {
main = document.querySelector("main").innerHTML;
}
let decodedSearch = decodeURIComponent(search.replace("?search=", ""));
if (main && decodedSearch) {
let searchIndex = main.toLowerCase().indexOf(decodedSearch.toLowerCase());
if (searchIndex !== -1) {
let searchElement = document.createElement("mark");
searchElement.textContent = main.substring(
searchIndex,
searchIndex + decodedSearch.length
);
let searchElementHTML = searchElement.outerHTML;
main =
main.substring(0, searchIndex) +
searchElementHTML +
main.substring(searchIndex + decodedSearch.length);
document.querySelector("main").innerHTML = main;
setTimeout(() => {
document.querySelector("mark").scrollIntoView();
}, 1000);
}
}
This JavaScript code highlights the first occurrence of a search term on a webpage based on a URL parameter and scrolls to it. Here's how it works:
Extract Search Term: Retrieves the search term from the URL parameter ?search= and decodes it.
Search the Page Content: Looks for the search term (case-insensitive) within the HTML content of the <main> element.
Highlight the Search Term: If the search term is found, wraps the first occurrence in a <mark> tag to visually highlight it.
Update Page Content: Replaces the original content of the <main> element with the modified content, now including the highlighted term.
Scroll to Highlight: After a short delay (1000ms) to allow all of the elements to fall into place, scrolls the page to bring the highlighted term into view.
This ensures users can easily locate the searched term within the page content when they click on a search result.
Concluding My Search For A New Search
Finally, I wanted to come up with a cool name for my search tool. One idea that came to mind was Soundwave, a Decepticon character from Transformers. Soundwave was a robot that could transform into a music cassette recorder. He often carried cassette-like minions—such as Laserbeak, Ravage, Rumble, and Frenzy—that transformed into various characters to assist him on missions.
The coolest of these minions, in my opinion, was Laserbeak. He was a small, bird-like Decepticon equipped with abilities perfect for reconnaissance and espionage. I thought this would make an awesome name. However, I had no intention of infringing upon the copyright of Transformers or its owners. So, I decided to take the "Transmorpher" route and came up with a similar-sounding name: Lazerback. And thus, my client-side web search was born.
Here's what the ChatGPT image generator came up with for Lazerback (I added the text using ArtText4).
Building my own search function was a rewarding experience. It allowed me to tailor the search to my site's needs, ensuring a seamless user experience. The lightweight, client-side solution doesn't rely on external libraries or server-side tools, making it easy for me to maintain and update. The search function is fast and intuitive, providing relevant results with minimal overhead.
It does not yet do everything I want it to do. But I can always expand it later because I full undestand what it does. For now, I'm happy with the results (though this might well end up being part one of many parts).