Enhancing client-side search with Lunr.js

When building a blog or website, having a fast and efficient search feature is essential for helping users find the content they need. For my site, I chose Lunr.js, a lightweight and powerful JavaScript library for full-text search. In this article, I’ll walk you through how I implemented Lunr.js to power the search functionality on my site. We won’t consider efficient caching methods in this article, but we can explore them in a future post.

The search UI

To make the search experience seamless, I created a modal-based search interface. The modal contains an input field for the search query and a list to display the results. Here’s the HTML structure:

<div id="search-modal" class="modal">
  <div class="modal-content">
    <input type="text" id="search-input" placeholder="🚀 Search for a blog post...">
    <span class="close">&times;</span>
    <ul id="search-results"></ul>
  </div>
</div>

The modal is styled to appear as an overlay, and the search results dynamically update as the user types in the input field. To get a sense of how it works, click the search icon or press CTRL + K.

Setting up Lunr.js

Instead of using a JSON file for the search index, I generate the index dynamically using a window.store object. This object is populated with metadata for all my blog posts during the build process. Here’s how I define it in my template:

<script>
  // Template to generate the search index
  
  window.store = {
    {% for post in site.posts %}
      "{{ post.url | slugify }}": {
        "title": "{{ post.title | xml_escape }}",
        "date": {{ post.date | date: "%b %-d, %Y" | jsonify }},
        "author": "{{ post.author | xml_escape }}",
        "category": "{{ post.category | xml_escape }}",
        "content": {{ post.content | strip_html | jsonify }},
        "url": "{{ post.url | xml_escape }}"
      }
      {% unless forloop.last %},{% endunless %}
    {% endfor %}
  };
  
</script>

This window.store object contains all the necessary data for Lunr.js to index and search through my blog posts.

Initializing Lunr.js and handling search queries

I use Lunr.js to create the search index directly in the browser. Since I don’t use a CDN, I include the Lunr.js file locally in my project:

<script src="/dario_blog/assets/js/lunr.min.js"></script>
<script src="/dario_blog/assets/js/search.js"></script>
<script src="/dario_blog/assets/js/modal.js"></script>

Here’s the JavaScript code I use to initialize Lunr.js and handle search queries:

(function () {
    let idx; // Declare a global variable to store the Lunr.js index

    function isModalShown(modal) {
        return modal.style.display === 'block';
    }

    function showResults(results, store) {
        const searchResults = document.getElementById('search-results');

        if (results.length) { // If there are results...
            let appendString = '';

            for (let i = 0; i < results.length; i++) {  // Iterate over them and generate html
                const item = store[results[i].ref];
                appendString += '<li><a href="'+ BASEURL + item.url + '" style="display: block; text-decoration: none; color: inherit;">';
                appendString += '<span class="post-meta">' + item.date + '</span><h2>' + item.title + '</h2>';
                appendString += '<p class="small">' + item.content.substring(0, 100) + '...</p>';
                appendString += '</a></li>';
            }

            searchResults.innerHTML = appendString;
        } else {
            searchResults.innerHTML = '<li>No results found</li>';
        }
    }

    function createIndex() {
        // Initialize lunr.js with the fields to search
        const index = lunr(function () {
            this.field('id');
            this.field('title', { boost: 10 });
            this.field('author');
            this.field('category');
            this.field('content');

            for (var key in window.store) { // Add the JSON generated from site content to Lunr.js
                this.add({
                    'id': key,
                    'title': window.store[key].title,
                    'author': window.store[key].author,
                    'category': window.store[key].category,
                    'content': window.store[key].content
                });
            }
        });
        // Save the index to session storage
        sessionStorage.setItem('lunrIndex', JSON.stringify(index));
        return index;
    }

    function loadIndex() {
        const savedIndex = sessionStorage.getItem('lunrIndex');
        if (savedIndex) {
            return lunr.Index.load(JSON.parse(savedIndex));
        }
        return null;
    }

    function doSearch(searchTerm) {
        if (!searchTerm) return;

        // Check if the index has already been created
        if (!idx) {
            idx = loadIndex() || createIndex();
        }

        const results = idx.search(searchTerm); // Perform search with Lunr.js
        showResults(results, window.store);
    }

    const modal = document.getElementById('search-modal');
    const searchBox = document.getElementById('search-input');
    const searchResults = document.getElementById('search-results');

    // Use MutationObserver to watch for changes in the modal's display style
    const observer = new MutationObserver(function (mutations) {
        mutations.forEach(function (mutation) {
            if (mutation.attributeName === 'style') {
                if (isModalShown(modal)) {
                    searchBox.addEventListener('keyup', onKeyUp);
                } else {
                    searchBox.removeEventListener('keyup', onKeyUp);
                    searchBox.value = ''; // Clear the search box when the modal is hidden
                    searchResults.innerHTML = ''; // Clear the search results when the modal is hidden
                }
            }
        });
    });

    observer.observe(modal, { attributes: true });

    function onKeyUp(event) {
        if (this.value.trim() === '') {
            searchResults.innerHTML = ''; // Clear the search results if the search box is empty
        } else {
            doSearch(this.value);
        }
    }
})();

This script initializes the Lunr.js index using the window.store object, dynamically updates the search results, and integrates with the modal UI.

Alternative: Using a JSON file for the index

If you prefer to use a JSON file instead of embedding the data in the window.store object, you can generate a JSON file during the build process and load it dynamically. Here’s an example of what the JSON file might look like:

[
  {
    "url": "/posts/introduction-to-lunrjs",
    "title": "Introduction to Lunr.js",
    "content": "Lunr.js is a powerful search library for static sites..."
  },
  {
    "url": "/posts/building-a-static-site-with-jekyll",
    "title": "Building a Static Site with Jekyll",
    "content": "Jekyll is a popular static site generator..."
  }
]

You can then load this file in your JavaScript:

fetch('/search-index.json')
  .then(response => response.json())
  .then(data => {
    const idx = lunr(function () {
      this.ref('url');
      this.field('title');
      this.field('content');

      data.forEach(doc => this.add(doc));
    });

    // Handle search input as shown earlier
  });

Alternative: Using a CDN for Lunr.js

If you don’t want to host Lunr.js locally, you can include it via a CDN:

<script src="https://cdnjs.cloudflare.com/ajax/libs/lunr.js/2.3.9/lunr.min.js"></script>

This is a quick and easy way to get started with Lunr.js.

Conclusion

Lunr.js is an excellent choice for adding search functionality to static sites. By using a window.store object, I’ve made the process of creating a search index directly in the browser simple and effective. For those who prefer a different approach, using a JSON file or a CDN are great alternatives.

With Lunr.js, I’ve created a fast and efficient search experience for my readers, all without requiring a backend. If you’re building a static site, I highly recommend giving Lunr.js a try!

If you have any questions or need help setting it up on your site, feel free to reach out. You can also check my GitHub repository for the full solution.

Help Menu

Press Ctrl+Q to open or close this help menu.

Searching

Click on the search icon or press Ctrl+K to search for a blog post.

To close the search menu, click the 'X' button, click outside the menu, press Esc, or use Ctrl+K

You can search by author, title, or article content

General

Click on the hamburger icon to open the navigation menu on mobile.

Click on the logo to go to the home page.

Click on the title of a blog post to read it.

About the Site

This site is a blog where I write about programming, web development, and other tech-related topics.

It is built using Jekyll, a static site generator.

It is hosted on GitHub Pages.

The 'Me' page is a bio.

The blog page contains articles I write.

The contact page has my contact details.

×