Implementing Client-Side Search with Lunr.js in Hugo
Overview
This website originally used a vanilla FlexSearch implementation, which had limitations in flexibility and control. We recently migrated to Lunr.js, a lightweight full-text search engine for JavaScript. This change provides a more robust and customizable search experience without requiring a server-side search backend.
This implementation heavily references the approach described in “The easier way to use lunr search with Hugo” by Wladimir Palant.
The Architecture
The search system consists of two main parts:
- Index Generation: Hugo generates a JSON file containing all searchable content during the site build.
- Client-Side Search: A JavaScript script loads the JSON index, builds a Lunr index in the browser, and executes search queries.
Step 1: Generating the Search Index
First, we need to tell Hugo to output a JSON version of our content. We configured a custom output format in hugo.toml:
[outputFormats.SearchIndex]
mediaType = "application/json"
baseName = "search"
isPlainText = true
[outputs]
home = ["HTML", "RSS", "SearchIndex"]Then, we created a template at layouts/index.searchindex.json to define the structure of the JSON file. This template iterates through all regular pages and extracts the title, permalink, description, tags, and plain text content.
{{- $pages := slice -}}
{{- range site.Pages -}}
{{- if and .Title .RelPermalink (or .Content .Params.description) -}}
{{- $content := .Plain | plainify | htmlUnescape -}}
{{- $content = replaceRE `\s+` " " $content | strings.TrimSpace | truncate 1000 -}}
{{- $page := dict "uri" .RelPermalink "title" .Title "content" $content "description" .Description "tags" .Params.tags -}}
{{- $pages = $pages | append $page -}}
{{- end -}}
{{- end -}}
{{- $pages | jsonify -}}Key aspects of this template:
- Content Cleaning: We strip HTML tags (
plainify) and replace excess whitespace to keep the index size manageable. - Truncation: Content is truncated to 1000 characters to prevent the index from becoming too large for client-side loading.
- Filtering: We only include pages that have a title and permalink.
Step 2: The Frontend Logic
The frontend logic is located in layouts/_partials/scripts/search.html. This script is included in the page footer and handles the search UI and logic.
Loading Lunr.js
We include the Lunr.js library (minified) directly:
<script defer src="/js/lunr.min.js"></script>Building the Index
The script fetches the search.json file and initializes the Lunr index. This happens asynchronously when the user interacts with the search input to avoid impacting initial page load performance.
async function loadSearchIndex() {
if (searchIndex) return searchIndex;
try {
const response = await fetch("/search.json");
searchData = await response.json();
searchIndex = lunr(function () {
this.ref("uri");
this.field("title", { boost: 10 });
this.field("content", { boost: 5 });
this.field("description", { boost: 3 });
this.field("tags", { boost: 2 });
searchData.forEach((doc) => this.add(doc));
});
return searchIndex;
} catch (error) {
console.error("Failed to load search index:", error);
return null;
}
}We configure specific fields and boosts:
- Title (Boost 10): Matches in the title are weighed most heavily.
- Content (Boost 5): Actual page content is the second most important.
- Description (Boost 3): The meta description.
- Tags (Boost 2): Tags associated with the post.
Executing Search
When a user types, we execute a query against the Lunr index. We use a wildcard (*) to support partial matches (fuzzy-like behavior for prefixes).
const searchResults = idx.search(query + "*");The results are then mapped back to the original data (title, excerpt) using the ref (URI) and rendered into the results list. We also include a custom highlighting function to bold matching terms in the results for better user feedback.
Why This Approach?
- Simplicity: No external search service (like Algolia) is required.
- Privacy: Everything runs in the client’s browser; no user data is sent to a third party.
- Performance: For a site of this size (hundreds of pages), the JSON index is small enough to download quickly, and Lunr.js is extremely fast.
This implementation provides a free and “premium” feel with instant feedback, keyboard navigation (arrow keys, Cmd+K shortcut), and accurate results, aligning with the overall aesthetic and usability goals of the site.