Adding dynamic search to a static Eleventy site

5 Minutes
Autumn forest with a magnifying glass, blurred background but clarity though the glass

Photo by Steven Wright on Unsplash

Switching to Eleventy

Until recently, my blog at JamesMcNee.co.uk was a single-page application built using the Angular framework a few years ago. I decided to build the blog using Angular because it was, and still is, the framework that I am most comfortable with. In my day-to-day work, if I need to whip up a quick web app for users to interact with a system that I am building, my go-to will be an Angular-based SPA.

With time, I started to call into question my decision to build a blog using the Angular framework. The site was using an old version of the framework and upgrading was taking work, it was using a bespoke custom CMS that I built, powered by a Java API and MongoDB store. It also had a lot of custom CSS, which is not my forte. I wanted something lighter, faster to load and easy to host and maintain.

It was time to explore one of these new-fangled static site generators that everyone has been raving about, promising to solve all the problems I had with my current set up. After a bit of googling, I decided to try out Eleventy, and I was immediately impressed by both the minimalism and flexibility it provides. So a few hours later, I had ported the pages and content, mainly keeping in the same style as the previous incarnation, but with the extra goodness of using Tailwind to do it.

The benefits that a static site provides in terms of web performance and the ease of adding new content are great, but it does pose a challenge for certain features that would be trivial with an API, in this case, search... I wanted to add a search component to my blog to allow for quick finding of posts by title and synopsis keywords.

So, like any good developer, I immediately went to Google in hopes of finding someone who has already done the work for me! Alas, my search did not yield a good example of how to implement what I wanted.

Building it from lab

After not having found a good example (there may be good examples, I just did not find one), I set in thinking about how I could implement it myself.

The back of the napkin requirements were simple, the solution should:

Creating a search payload

The first step to implement this was to get a data source in place, a simple JSON page served on a static route should do it. Here is an example of a nunjucks template that gives the search information for each post on my blog:

search.json.njk

---json
{
    "permalink": "search.json",
    "eleventyExcludeFromCollections": true,
    "metadata": {
        "url": "https://www.jamesmcnee.com/"
    }
}
---
{
    "results": [
        {%- for post in collections.posts | reverse %}
        {%- set absolutePostUrl = post.url | absoluteUrl(metadata.url) %}
        {
            "url": "{{ absolutePostUrl }}",
            "path": "{{ post.url }}",
            "title": "{{ post.data.title }}",
            "synopsis": "{{ post.data.synopsis }}"
        }
        {% if not loop.last %},{% endif %}
        {%- endfor %}
    ]
}

This template yields an endpoint at /search.json with the following:

{
  "results": [
    ...
    {
      "url": "https://www.jamesmcnee.com/blog/posts/2023/sep/29/eleventy-search/",
      "path": "/blog/posts/2023/sep/29/eleventy-search/",
      "title": "Adding dynamic search to a static Eleventy site",
      "synopsis": "This post covers how I went about adding a dynamic search element to a static Eleventy based blog, without compromising on the benefits of SSG."
    },
    ...
  ]
}

This should be enough information to facilitate a search by keyword in the posts title or synopsis text and allow for linking off to the full article.

Creating the markup

Next, we need a search component, essentially a text input which can show search results below it.

    <div class="flex">
        <span class="flex-1"></span>
        <div>
            <div class="form-control w-full max-w-xs">
                <label class="label" for="search">
                    <span class="label-text">Search Posts:</span>
                    <span class="label-text-alt">e.g. "Patch"</span>
                </label>
                <input id="search" autocomplete="off" type="search" placeholder="Type here" class="input input-bordered w-full max-w-xs" />
            </div>
            <div id="search-results-container" class="card shadow-xl bg-base-100 absolute border-gray-600 border-solid border-2 translate-y-2 left-0 ml-[2%] w-[94%] z-50 md:left-auto md:w-96 md:-translate-x-1/2 lg:-translate-x-1/4 invisible">
                <div id="search-results" class="card-body p-4 text-sm max-h-96 overflow-y-scroll"></div>
            </div>
        </div>
    </div>
Post One

This is the synopsis for the first post, it gives a brief description as to its content.

Post Two

This is the synopsis for the second post, it gives a brief description as to its content.

The above is what I came up with, if you want to use it for inspiration, you can find the full markup for it over on my GitHub.

Implementing the dynamic results

Now that I had the markup for searching and displaying the results, I just needed to write a bit of Javascript to wire it up to the search payload we created earlier... For this, I just used an inline <script> block as it seemed the simplest solution.

<script type="text/javascript">
    function searchShouldBeVisible(visible) {
        document.getElementById('search-results-container').classList.remove(visible ? 'invisible' : 'visible')
        document.getElementById('search-results-container').classList.add(visible ? 'visible' : 'invisible')
    }

    function search(term) {
        // Set search results to invisible if the term is falsy (empty string, or null/undefined)
        if (!term) {
            searchShouldBeVisible(false)
            return
        }

        // Set search results to visible
        searchShouldBeVisible(true)

        // Fetch the full search payload
        const searchResponse = await fetch('/search.json')
        const responseBody = await searchResponse.json()

        // Filter the results array for items that contain the term (ignoring case)
        const filtered = responseBody.results.filter(item => `${item.title}${item.synopsis}`.toLowerCase().includes(term.toLowerCase()))

        // Get the DOM element to populate with results
        const resultsDiv = document.getElementById("search-results")

        // Special handling if nothing found
        if (filtered.length === 0) {
            resultsDiv.innerHTML = 'Nothing found...'
            return
        }

        // Build up the inner HTML for the search results div
        let compiledString = ''
        for (let i = 0; i < filtered.length; i++) {
            const post = filtered[i]
            const card = `<div class="cursor-pointer" onclick="window.location = '${post.path}';"><a class="font-bold mb-0" href="${post.path}">${post.title}</a><p class="mb-0">${post.synopsis}</p></div>`
            compiledString = `${compiledString}${card}`

            if (i !== filtered.length - 1) {
                compiledString = `${compiledString}<span class="divider mt-0.5 mb-0.5"></span>`
            }
        }

        // Assign the compiled string to the innerHTML for the div
        resultsDiv.innerHTML = compiledString
    })
</script>

The above is a slightly simplified version of what I ended up with as I also wanted the following features:

You can, if desired, have a gander at the full implementation of this on the GitHub repository for this blog. Do note that some of the required functions are off in other files though.

Tying it all together

All that is left now is to wire up the search input to the function we have created above.

<input id="search" autocomplete="off" type="search" placeholder="Type here" class="input input-bordered w-full max-w-xs" 
       onkeyup="search(event.target.value)" />
Image showing a search box with some dummy results under it

Summary

In this post we have explored how we can add a bit of dynamic flare to an otherwise static site and implement a useful and fully customisable search, whilst not having to leave the framework!

If desired, you could extend this to also:

Comments

Please feel free to share your thoughts and questions about this post!

Spotted a mistake?

Edit on GitHub