2222 words
11 minutes
A Guide to Building Your Own Static Site Search with Python and Lunr.js

Building Static Site Search with Python and Lunr.js

Static websites offer significant advantages in performance, security, and scalability by serving pre-rendered HTML, CSS, and JavaScript files directly from a server or Content Delivery Network (CDN). Unlike dynamic sites that generate content on the fly using server-side databases and logic, static sites consist of fixed files. However, this static nature traditionally posed a challenge for implementing site search functionality, which typically requires processing user queries against a collection of documents.

Server-side search solutions, common for dynamic sites, index content on the server and process queries there. For static sites, this would necessitate a separate backend service, adding complexity and cost. An alternative is client-side search, where the entire search index is downloaded to the user’s browser, and the search operation is performed using client-side JavaScript. This approach keeps the static site architecture intact.

Implementing client-side search requires a method to create a search index from the static site’s content and a JavaScript library capable of performing search queries against that index within the user’s browser. This guide explores using Python to generate the search index and Lunr.js, a lightweight JavaScript search library, for the in-browser search functionality.

The Need for Client-Side Search on Static Sites

While external search engines index publicly accessible static sites, providing internal site search offers a focused and controlled user experience. Internal search allows visitors to quickly find specific information within the site’s structure, improving navigation and content discovery.

For static sites, employing a client-side search solution presents several benefits:

  • Performance: Search queries are processed locally in the user’s browser, potentially reducing latency compared to round trips to a separate search server.
  • Cost-Effectiveness: Eliminates the need for a dedicated search server or database, aligning with the low operational cost model of static sites.
  • Simplicity: Integrates directly into the front-end code, avoiding backend dependencies beyond serving static files.
  • Scalability: Scales effortlessly as site traffic increases, as the search load is distributed across client browsers rather than centralized on a server.

Core Components: Python and Lunr.js

Building static site search with this approach relies on two main components:

  • Python: A versatile programming language used here for indexing. Python scripts read the static site’s content files (e.g., HTML, Markdown), extract relevant text and metadata, and structure this information into a format suitable for search.
  • Lunr.js: A small, full-text search library written in JavaScript. Lunr.js runs entirely in the user’s browser. It loads the index generated by Python and performs search operations against it when a user enters a query. Lunr.js supports features like tokenization, stemming, stop word removal, and basic Boolean logic, providing a reasonably sophisticated search experience client-side.

The workflow involves an offline indexing phase using Python and an online search phase in the user’s browser using Lunr.js.

The Indexing Process Explained

The crucial step is generating the search index file that Lunr.js will consume. This is where Python plays its role. The process typically follows these steps:

  1. Content Identification: The Python script needs to locate all the relevant content files within the static site’s directory structure. These are typically HTML files or source files (like Markdown) from which HTML is generated by a static site generator (SSG) such as Jekyll, Hugo, or Sphinx.
  2. Content Parsing: For each identified content file, the script must extract the data that should be searchable. This includes:
    • The document’s URL or identifier.
    • The document’s title.
    • The main body text.
    • Potentially other metadata like tags, categories, or publication dates. Parsing HTML requires an HTML parsing library (like BeautifulSoup in Python) to navigate the document structure and extract text while ignoring navigation, headers, footers, or code blocks that are not part of the main content. Parsing Markdown or other source formats might involve different libraries or leveraging the SSG’s capabilities if available.
  3. Data Structuring: The extracted data for each document needs to be formatted into a structured collection that Lunr.js understands. Lunr.js works with a list of JavaScript objects (which translates directly from a Python list of dictionaries), where each object represents a searchable document. Each document object must have a unique identifier and fields corresponding to the parts of the document that should be indexed (e.g., id, title, body, url).
  4. Index Generation: The structured data collection is then saved to a file, typically in JSON (JavaScript Object Notation) format. JSON is a lightweight data-interchange format that is easily readable by JavaScript, making it ideal for Lunr.js to load.

This Python script is typically run during the static site build process, either manually or as part of the SSG’s build pipeline, to ensure the search index is up-to-date whenever content changes.

Step-by-Step Guide: Building the Index with Python

Here is a conceptual breakdown and structural guidance for the Python script.

Step 1: Set up the Python Environment#

Ensure Python 3 is installed. Install necessary libraries. For parsing HTML, BeautifulSoup is a common choice. The standard json library handles JSON output.

Terminal window
pip install beautifulsoup4

Step 2: Write the Content Scraper/Parser#

The core of the script involves iterating through content files and extracting data.

import os
import json
from bs4 import BeautifulSoup
def extract_content(filepath):
"""Reads a file and extracts content."""
# Placeholder: Implement logic based on file type (HTML, Markdown source, etc.)
# For HTML:
try:
with open(filepath, 'r', encoding='utf-8') as f:
soup = BeautifulSoup(f, 'html.parser')
title_tag = soup.find('title')
title = title_tag.string if title_tag else os.path.basename(filepath)
# Example: Extract text from a specific article container
article_body = soup.find('article') # Adjust selector based on site structure
body_text = article_body.get_text(separator=' ', strip=True) if article_body else ""
# Determine the URL (requires mapping file paths to site URLs)
# This mapping logic is site-specific. Example: remove file extension, replace directory separator
base_url = "https://your-site.com/" # Replace with actual base URL
relative_path = os.path.relpath(filepath, 'path/to/your/content/directory') # Adjust path
url = base_url + os.path.splitext(relative_path)[0] + '/' # Adjust for permalink structure
return {
'id': url, # Use URL as a unique ID
'title': title,
'body': body_text,
'url': url
}
except Exception as e:
print(f"Error processing {filepath}: {e}")
return None
def collect_files(directory, extensions=['.html']):
"""Collects files with specified extensions from a directory."""
files_list = []
for root, _, files in os.walk(directory):
for file in files:
if os.path.splitext(file)[1].lower() in extensions:
files_list.append(os.path.join(root, file))
return files_list
# Example Usage:
# content_directory = 'path/to/your/static/site/output' # Directory containing HTML files
# html_files = collect_files(content_directory)
# documents = [extract_content(f) for f in html_files if extract_content(f)] # Filter out errors
  • Insight: Parsing HTML requires careful consideration of the site’s structure to ensure only relevant content is extracted. Using specific CSS selectors or traversing the DOM based on known structural elements (like an <article> tag) is more reliable than extracting all text.
  • Data Point: A typical site might have 80% of its HTML dedicated to navigation, headers, footers, and sidebars. Efficient parsing reduces the index size and improves search relevance by focusing on core content.

Step 3: Generate the Lunr.js Index Data Structure#

The documents list generated in Step 2 is already in the required format: a list of dictionaries, each representing a document with fields like id, title, body, and url.

# 'documents' is the list of dictionaries from Step 2
# Example structure:
# documents = [
# {'id': 'https://site.com/post1/', 'title': 'Post One', 'body': 'Content of post one...', 'url': 'https://site.com/post1/'},
# {'id': 'https://site.com/page2/', 'title': 'Page Two', 'body': 'Content of page two...', 'url': 'https://site.com/page2/'},
# # ... more documents
# ]

Step 4: Save the Index as JSON#

Save the documents list to a JSON file.

# Example Usage (continuing from Step 2 & 3):
# output_index_file = 'static/search_index.json' # Where the JSON file will be saved in your static site
# with open(output_index_file, 'w', encoding='utf-8') as f:
# json.dump(documents, f, ensure_ascii=False, indent=2)
# print(f"Index generated successfully to {output_index_file}")
  • Tip: Using ensure_ascii=False ensures non-ASCII characters (like accented letters) are saved correctly, and indent=2 makes the JSON file human-readable for debugging (though increasing file size). For production, remove indent.

This Python script is a build tool. It runs before deployment to create the search_index.json file, which is then deployed alongside the static site files.

Step-by-Step Guide: Integrating Lunr.js into the Static Site

This involves adding JavaScript to the static site’s front-end code.

Step 1: Include Lunr.js Library#

Download Lunr.js or link to a CDN. Include it in the site’s HTML, typically at the end of the <body> tag or in the <head>.

<script src="https://unpkg.com/lunr/lunr.js"></script>

Step 2: Load the Generated JSON Index File#

Fetch the search_index.json file generated by the Python script. This is an asynchronous operation.

let searchIndex;
let documents = []; // Store the full document data
fetch('/search_index.json') // Path to your JSON file
.then(response => response.json())
.then(data => {
documents = data; // Store the full document data
// Create the Lunr index
searchIndex = lunr(function () {
this.ref('id'); // 'id' field from the JSON is the unique identifier
this.field('title', { boost: 10 }); // Boost title matches
this.field('body');
this.field('url'); // Can index URL too if needed for search terms
documents.forEach(function (doc) {
this.add(doc);
}, this);
});
})
.catch(error => {
console.error('Error loading search index:', error);
});
  • Insight: Loading the index asynchronously prevents blocking the main thread and keeps the page responsive while the file is downloaded and processed.
  • Data Point: The size of the search_index.json file directly impacts the time required to download and process it. For sites with hundreds or thousands of documents, managing index size becomes important. Techniques like indexing only essential fields or creating multiple smaller indices might be considered for very large sites.

Step 3: Implement the Search UI and Logic#

Add an input field for search queries and an area to display results. Add JavaScript to handle user input and perform the search using Lunr.js.

<input type="text" id="search-input" placeholder="Search site...">
<ul id="search-results"></ul>
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
searchInput.addEventListener('input', function(event) {
const query = event.target.value;
// Clear previous results
searchResults.innerHTML = '';
if (query.length < 2) { // Require at least 2 characters to search
return;
}
if (searchIndex) {
try {
const results = searchIndex.search(query); // Perform the search
if (results.length > 0) {
// Display results
results.forEach(result => {
// Find the full document data using the result ref (id)
const doc = documents.find(d => d.id === result.ref);
if (doc) {
const li = document.createElement('li');
li.innerHTML = `<a href="${doc.url}">${doc.title}</a>`; // Example output
searchResults.appendChild(li);
}
});
} else {
const li = document.createElement('li');
li.textContent = "No results found.";
searchResults.appendChild(li);
}
} catch (e) {
console.error("Search error:", e);
// Handle potential errors during search parsing (e.g., invalid query syntax)
const li = document.createElement('li');
li.textContent = "Error performing search. Please try a different query.";
searchResults.appendChild(li);
}
} else {
// Index not yet loaded
const li = document.createElement('li');
li.textContent = "Search index not yet loaded. Please wait.";
searchResults.appendChild(li);
}
});
  • Tip: Add debounce or throttle to the input event listener for performance on large indices, preventing searches on every single keystroke.
  • Usability: Providing feedback while the index loads or when no results are found enhances the user experience.

Refining the Search Experience

Several techniques can improve the quality and performance of the client-side search:

  • Field Weighting: As shown in the Lunr.js initialization, weighting fields (this.field('title', { boost: 10 })) makes matches in the title field more relevant than matches in the body. This prioritizes results where the search term is a primary topic.
  • Stop Words and Stemming: Lunr.js includes built-in support for removing common words (stop words like “the”, “a”) and reducing words to their root form (stemming, e.g., “running”, “ran” become “run”). These features are typically enabled by default and improve search accuracy by matching variations of a word.
  • Handling Large Indices: For very large static sites (thousands of pages), a single large JSON index can be slow to download and process. Potential strategies include:
    • Creating multiple smaller indices (e.g., per section or year) and searching only the most relevant ones first.
    • Only indexing titles and maybe the first paragraph of content, keeping the index size down.
  • UI/UX Improvements: Implementing features like search suggestions, highlighting search terms in results, and a dedicated search results page rather than just an inline list can significantly improve usability.

Case Study Example

A technical documentation website for an open-source project, built using Sphinx (an SSG), required offline search capabilities for users downloading documentation archives. Implementing a server-side search was deemed too complex and added an unnecessary dependency.

The solution involved:

  1. A Python script that ran after the Sphinx build process. It parsed the generated HTML files, extracting the page title, URL, and the main content from specific HTML divs unique to the documentation pages.
  2. The script formatted this data into a JSON list.
  3. The Lunr.js library was included in the Sphinx HTML theme.
  4. JavaScript code fetched the generated JSON index, initialized Lunr.js, and powered a search box in the site’s header, displaying results in a drop-down list.

This approach successfully delivered fast, client-side search within the downloaded documentation archives, fulfilling the offline requirement without any server infrastructure, aligning perfectly with the project’s static documentation build process. The index size remained manageable for several hundred documentation pages.

Key Takeaways

  • Building static site search with Python and Lunr.js leverages Python for offline index generation and Lunr.js for client-side search execution.
  • This approach offers performance, cost, and simplicity benefits compared to server-side search solutions for static websites.
  • The process involves a Python script parsing static content (like HTML), extracting key data (title, body, URL), and formatting it into a JSON index file.
  • Client-side integration requires including the Lunr.js library and JavaScript code to fetch the JSON index, initialize Lunr.js, and handle search queries and result display.
  • Effective content parsing in Python, considering HTML structure, is critical for accurate index generation.
  • Optimizing index size and using Lunr.js features like field weighting and stemming improve search relevance and performance.
  • Asynchronous index loading in JavaScript is recommended for better user experience.
  • This method is suitable for static sites where maintaining a simple architecture and minimizing server dependencies are priorities.
A Guide to Building Your Own Static Site Search with Python and Lunr.js
https://dev-resources.site/posts/a-guide-to-building-your-own-static-site-search-with-python-and-lunrjs/
Author
Dev-Resources
Published at
2025-06-30
License
CC BY-NC-SA 4.0