Static search with Fuse.js

Photo by Markus Winkler on Unsplash

Static search with Fuse.js

After I've finished my first blog posts I thought about a search - no one wants to search by clicking through the pagination.

My requirements were:

  • easy to implement
  • compatible with static deployment (no backend)
  • free or at least predictable costs
  • customizable UI/theme

My frontend stack is Tailwind CSS and Alpine.JS so in the best case the search would be headless and only provide a list of matching entries.

The first that came to mind was checking my hoster Netlify and yes, it has a plug-in that integrated well known Algolia search via crawler. After playing with it for some time I've seen that the Algolia plug-in isn't integrable with manual deployments as these don't trigger build hooks. And it's also not possible to trigger it in a local environment via Netlify CLI.

So I had to search again and found a plug-in that generates as Fuse.js compatible JSON file, but as it's a crawler I had no control over what's indexed. It always included the header and footer text. But I had a tool that I already had on my list of things to play with some time ago.

As I have all my content in markdown files with YAML front matter with PHP classes similar to models - powered by spatie/sheets - I have no problem to generate my own JSON file.

Search index JSON

Fuse.js accepts a simple JavaScript array, so I've created a route that returns all my posts reduced to the important attributes as JSON. The following code is for Laravel but the general idea can be transferred to every language and framework.

php
use App\Post;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Support\Facades\Route;

Route::get('blog/search.json', function (): Jsonable {
    return Post::all()->map(fn (Post $post): array => [
        'url' => $post->url,
        'title' => $post->title,
        'date' => $post->date->format('M jS, Y'),
        'categories' => $post->categories,
        'description' => $post->description,
        'content' => $post->markdown,
    ]);
})->name('blog.search');

This route will be exported to a static JSON file during deployment, but it works the same during local development and on static production.

Initialising Fuse.js

To initialize Fuse.js we first have to retrieve the JSON file. As I don't want any additional dependency for an Ajax request and also don't have any headers or similar to configure I've decided for fetch().

js
const url = new URL(window.location);
url.pathname = "blog/search.json";
url.searchParams.set("t", Date.now());

const items = await fetch(url.toString())
  .then((response) => response.json())
  .catch(console.error);

Now we have our records in the clients' browser and can initialize Fuse.js with it.

js
import Fuse from "fuse.js/dist/fuse.basic.esm";

const fuse = new Fuse(items, {
  includeScore: true,
  minMatchCharLength: 3,
  keys: ["title", "description", "categories", "content"],
});

That's it, we have a working client-side search engine. To filter the items by a query we can use the search() method on the initialized fuse engine.

js
const results = fuse.search("Laravel");

Alpine.js component

Until now the search isn't usable as there's no input and the results are also not rendered.

So I've created a new Alpine.js "component" that does all this. It also contains the snippets from above to initialize the search engine on component init.

js
import Fuse from "fuse.js/dist/fuse.basic.esm";
window._ = require("lodash");
window.search = {
  items: null,
  fuse: null,
  query: "",
  results: [],
  init() {
    let url = new URL(window.location);
    url.pathname = "blog/search.json";
    url.searchParams.set("t", Date.now());

    fetch(url.toString())
      .then((response) => response.json())
      .then((items) => {
        this.items = items;

        this.fuse = new Fuse(this.items, {
          includeScore: true,
          minMatchCharLength: 3,
          keys: ["title", "description", "categories", "content"],
        });
      })
      .catch(console.error);
  },
  search() {
    if (this.fuse === null) {
      this.results = [];
      return false;
    }

    this.results = _(this.fuse.search(this.query))
      .orderBy("score", "desc")
      .take(3)
      .map((r) => r.item)
      .values();
  },
};

And the HTML part using this JavaScript object as Alpine.js data.

html
<div x-data="window.search" class="space-y-2 mb-4 md:mb-8 lg:mb-10 xl:mb-12">
  <input
    type="search"
    name="search"
    placeholder="Search &mldr;"
    autocomplete="off"
    @input.debounce.250ms="search"
    x-model="query"
    class="px-4 py-2 w-full bg-white dark:bg-night-10 border-b-2 border-night-10 dark:border-snow-10 rounded-1 focus:outline-none focus:border-brand shadow"
  />
  <ol class="list-none space-y-2" :class="{'hidden': results.length == 0}">
    <template x-for="result in results">
      <li
        class="rounded-1 shadow bg-white dark:bg-night-20 overflow-hidden p-4"
      >
        <a :href="result.url" class="block group">
          <div class="flex justify-between sm:justify-start space-x-2">
            <strong
              x-text="result.title"
              class="group-hover:text-brand"
            ></strong>
            <span class="text-snow-20 dark:text-snow-10">
              <x-icon class="fal mr-1 fa-calendar" />
              <time x-text="result.date"></time>
            </span>
          </div>
          <p class="truncate" x-text="result.description"></p>
        </a>
      </li>
    </template>
  </ol>
</div>

Now we have a fully working blog post search.

Notes

Some notes afterward. For sure this isn't a perfect solution for every site/use case. Tools like Algolia or ElasticSearch have their pros!

This solution will also not work endless, the amount of JSON that's transferred and parsed is limited. I'm not sure if it's Netlify, the browser or something else but I had to split a ~35MB JSON file into chunks of ~6MB to still work properly in another project. That's also the reason why I've not included the whole content until now.

The first way to improve it would be using a FaaS to run the Fuse.js server-side and only transfer the matches. You could even prepare the data server-side via lodash and so on to only contain what the client needs.

Cloudflare workers

I haven't used them until now but so far I've understood them they would be like the perfect solution for server-side searching as they can manipulate the original response.

The idea would be to have an URL like /search?q=Laravel and the worker retrieves the full search.json, instantiates fuse, filters the posts and returns only the matched entries.

The pseudo-code idea is:

js
let entries = require("search.json");
const fuse = new Fuse(entries);
return fuse.search(request.query.get("q"));