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="/assets/js/lunr.min.js"></script>
<script src="/assets/js/search.js"></script>
<script src="/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.