Adding Search: Refining The Frontend (Algolia, Gatsby, Craft CMS - Part 3)

By this point, I'd managed to populate an Algolia search index, integrate it with Gatsby, and created a specific search page. The MVP was effectively complete, but it wasn't exactly pretty. The next step was to extend the core functionality and build out my site search capabilities.

Custom Search Components

I have a slightly irrational distrust of other people's code, especially when it's abstracted. I feel that opaque dependencies[1] become "black boxes", sections of code which information disappears into and produces something useful, but which you can't really grok, and I don't like that feeling. Algolia's search components are classic examples of black boxes: they do something incredibly useful, they work out of the box, and they can be a complete pain if you want to slightly tweak them. Don't want your search results returned as a list? Tough, that's just what the black box spits out.

Yes, you can use a slightly hacky CSS workaround (see previous post), but that could be problematic in the future. What happens if those class names are updated in a later release? I'm left patching a UI bug that I didn't create – no thanks! Luckily, the team at Algolia haven't just created a bunch of neat little ways to extend their components, they've also given us the power to utterly rewrite them. They're still a bit of a black box, requiring certain inputs which just magically work, but at least the UI is now entirely editable. I did mess around with a bit of this last time via the hitComponent attribute, but that still left unwanted wrapping elements. Instead, let's create some bespoke, customised components!

Custom Hits

I've already got a PostPreview component set up, but it's currently being used within an attribute. I want to invert that relationship and have my component as the parent. First, let's import connectHits:

import { Highlight, connectHits } from "react-instantsearch-dom"

Next, we'll change the name of the main function to CustomHitPreview, pass it the parent element with all returned searches (hits) rather than each returned object (hit), and then wrap the existing markup in a <section> containing a mapped array:

const CustomHitPreview = ({ hits }) => {
    return (
        <section className={styles.search_results}>
            {hits.map((hit, index) => {
                let searchResult = hit.node
                let type = searchResult.contentType.substring(0, searchResult.contentType.length - 1)
                let url = searchResult.contentType === "articles" ? "article" : searchResult.contentType === "journals" ? `journal/${searchResult.year}/${searchResult.month.toLowerCase()}` : searchResult.contentType
                return (
                    <article key={index}>
                        <h2>
                            <Highlight attribute="node.title" hit={hit} />
                        </h2>
                        <p>
                            <Highlight attribute="node.sanitised" hit={hit} />
                        </p>
                        <p>
                            <Link to={`${url}/${searchResult.slug}`}>Read the full {type}.</Link> Date posted: {searchResult.month} {searchResult.year}
                        </p>
                    </article>
                )
            })}
        </section>
    )
}

About 90% of the original code is exactly the same. We're still defining the layout of each returned entry, but rather than doing so within a subcomponent, we're now in control of mapping the original query response. That allows us to get rid of the <ul> element entirely, which is what I wanted. It also lets us set our own class names, so we don't need to worry about future code changes that are out of our control. Perfect.

The last thing to do is to pipe this up to the InstantSearch structure by telling it that PostPreview effectively extends the existing Hits component, which we can do using the following line:

export const PostPreview = connectHits(CustomHitPreview)

Custom Search Box

I used the same method to also create a new component called SearchBox which replaces the default version from InstantSearch. The core concepts are all the same: create a new markup structure and export it using the connectSearchBox function so that InstantSearch knows what core functionality should be made available. Here's what that looks like:

import React from "react"
import { connectSearchBox } from "react-instantsearch-dom"

import styles from "./search.module.css"

const SearchBox = ({ currentRefinement, isSearchStalled, refine }) => (
    <form className={styles.search_box} noValidate role="search">
        <input type="search" value={currentRefinement} onChange={(event) => refine(event.currentTarget.value)} />
        <button onClick={() => refine("")}>
            Reset query
        </button>
        {isSearchStalled ? <p>Sorry, search is stalling, please wait a moment.</p> : ""}
    </form>
)

export const CustomSearchBox = connectSearchBox(SearchBox)

I'm still making use of some inbuilt functions – like isSearchStalled which lets us show an error message if the response is taking a while – but now I have full control of the structure and style of the input method.

Except... doing so resulted in an irritating new behaviour: the page reloaded every time the refresh button is clicked or the user hit enter. That's not ideal. To fix that, I removed the default form behaviour by first adding type="button" to the <button>, which overrides the browser default of submit.

Next, I changed the form submission behaviour. Because this is Gatsby, that meant using the onSubmit hook and calling preventDefault like so:

<form className={styles.search_box} noValidate role="search" onSubmit={(e) => e.preventDefault()}>

Notice that I also added a default action value. You still want an action attribute as that's just best practice, but it shouldn't do anything. I've seen a lot of people use action="#" but that never feels quite right to me, so I prefer this method.

Update: React now throws a warning for any instance of "javascript:void(0);", so I did some digging around. According to the latest HTML5 spec you can now leave off the action attribute, so, for now, that's what I'm doing.

Using Custom Components

Finally, I hooked the new components up to the original Search component, which was extremely easy. I just removed SearchBox and Hits from the InstantSearch import and replaced them with my own[2]:

import { PostPreview } from "./post_preview"
import { CustomSearchBox } from "./search_box"

Then I just changed the layout to use them instead of the existing setup:

<InstantSearch indexName={searchIndex} searchClient={searchClient}>
    <CustomSearchBox defaultRefinement="" />
    <PostPreview />
    <PoweredBy />
</InstantSearch>

And that's it. With a little CSS applied, I had a much nicer looking search box and reset button:

Colourful searchbar design with placeholder text reading "search archives" and a refresh button..
Doesn't that look world's better 😉

Extending The Search Components

Okay, so the search bar looks half decent, it returns useful search results, and the whole thing is rigged up neatly. Next, I wanted to customise it for my own use cases.

Autoselecting Current Search Text

It's a small thing, but if I've typed in a search query, browsed around the list, and then come back to the search box again, I'd prefer to skip the refresh button and just select what's present. Lazy? Sure. Better UX? Definitely. However, I don't want the input to clear, as sometimes I might want to augment or adjust the previous search (typos, for example, or increased specificity).

React makes this a little harder in some ways, but there's a basic pattern you get used to using: create a small event function and call that from the onClick hook. It looks something like this (middle code removed for space):

const handleFocus = (event) => event.target.select()

const SearchBox = {...
        <input type="search" value={currentRefinement} onChange={(event) => refine(event.currentTarget.value)} placeholder="Search archives" onClick={handleFocus} />
        {...}
}

Now, if there's any non-placeholder text in the search box when you click on it the previous search will be fully selected, ready to be written over or amended.

Persisting Searches

Here's a common user journey: search for something, see a result, click on it, read the article, hit back in order to return to the search results. Maybe the article didn't answer your question, maybe you clicked the link by accident, maybe you just saw multiple useful results (one can dream). Whatever the reason, it's annoying when you go back to the search page to find it reset. Well, out of the box that's what Algolia does, so I wanted to fix that.

As I'm using React, my initial thought was to, well, use React's state model. There's even a useful prop built into InstantSearch called currentRefinement which tracks the user input within the search box, which felt like a good starting point. Except, that kind of state is inherently restricted to the client. Sure, we can use global state or get fancy with something like React Router, but it's useful to be able to link to search results from other websites, which means being able to persist search state between both clients and time. That sounds like the job of a URL, which comes with the added bonus of just working with browser back buttons. Ideal.

Step one was to get the current search term to update the URL. I did try playing around with Algolia's own history functions, but having added almost 30 lines of code I still wasn't getting anywhere and it felt like I was just trying to reinvent a wheel. Specifically, the wheel of window.history.replaceState. So I threw that all out and wrote this function:

const updateQuery = (query) => window.history.replaceState(null, null, `?query=${query}`)

Now I've gotta raise my hand here and say that I don't know whether using a blank state is good or bad, but it works so it'll do.

Ideally, the URL should change at the same time as the actual search value, which is done via the onChange event hook. React can be a little funny with chaining functions within event hooks, so I had to abstract the current setup to a new function that did everything I wanted:

const userSearch = (event, refine) => {
    refine(event.currentTarget.value)
    updateQuery(event.currentTarget.value)
}

With an <input> element of:

<input type="search" value="{currentRefinement}" onchange="{(event)" ==""> userSearch(event, refine)} placeholder="Search archives" onClick={handleFocus} />

But now the reset button was broken, so I had to modify its onClick event in the same way and create a new function that reset both the URL and the search box:

const resetSearch = (refine) => {
    refine("")
    updateQuery("")
}
{...}
<button onClick={() => resetSearch(refine)} type="button" title="Reset search"><span role="img" aria-label="Reset">🔄</span></button>

Great, now whatever you type into the search box is instantly visible in the query parameter added to the URL; anything already present is just overwritten. I could test persistence by typing something into the box, then clicking any link on the page and instantly hitting the back button; the URL parameter is still there[3].

The next step was to retrieve that URL parameter when the page initially loads and set it as the search value, so that links from external sources render correctly. Luckily, Algolia gives us the defeaultRefinement prop, so we just need to grab the URL and pass it to that. Rather than prop drilling Gatsby's inbuilt location, I pulled the globalHistory element out of Reach Router, queried the search attribute, and then stripped away the unwanted bits of the string, like so[4]:

import { globalHistory } from "@reach/router"

export default function Search() {
    let urlQuery = globalHistory.location.search ? globalHistory.location.search.replace("?query=", "") : ""
    {...}
}

Then all I needed to do was pass that value into defeaultRefinement and it was good to go:

<CustomSearchBox defaultRefinement={urlQuery} />

Now, when I load a page with a URL parameter string my code will just grab it and input it to the search box. That opens up some interesting use cases, but most importantly it means I can link to search results directly. I can even use Algolia to effectively create category and tag pages, like so: Technology (category) or a11y (tag).

Filtering Results on Category

I've spent the last few weeks coming up with an information taxonomy, which is obviously massively useful for site search. Still, at this point, all that added usefulness was hidden in the search index, whereas it would be most beneficial to surface it directly on the search page. The simplest method would be to filter on category and Algolia has a specific widget for this called the RefinementList, so that's what I used.

I imported the RefinementList component from InstantSearch, then dropped it into my template with the required filtering attribute. I want to sort on categories, so that looks like this:

<RefinementList attribute="node.categories" />

You will need to define the attribute as a "facet" in the Algolia control panel before anything shows up, but once you do you should see something like this:

Bullet list of categories with checkboxes and total number of results displayed.
It's neat that you get result numbers straight out of the box, but otherwise it leaves a lot to be desired as a UI.

That's some pretty great initial functionality but, as tends to be the case, it looks a bit rubbish. Similar to the other InstantSearch components, I created a custom version for styling (this works the same way as the other two detailed above). I started with something pretty close to the default component:

const CategoryFilter = ({ items, refine }) => {
    return (
        <ul className={styles.category_list}>
            {items.map((item) => (
                <li key={item.label}>
                    <label style={{ fontWeight: item.isRefined ? "bold" : "" }}>
                        <input type="checkbox" onClick={(event) => refine(item.value)} />
                        {item.label} <span>{item.count}</span>
                    </label>
                </li>
            ))}
        </ul>
    )
}

export const CustomCategoryFilter = connectRefinementList(CategoryFilter)

Most of the elements are the same, but now that I control the CSS it was pretty easy to completely change the look and feel:

Styled category list with yellow backgrounds for selected filters and green for the rest.
The yellow background shows the selected filters. I had to play around a bit with the sorting of the widget to stop these shuffling all the time.

The only thing left was to connect the new filters up to the URL parameter function, so that they can be remembered and passed around as well. Except that turned out to be quite tricky. For starters, I needed two URL parameters going forward, but I didn't want one to override the other. That meant creating a new function that could return the current parameter values and split them into an array:

const getSearch = () => {
    let currentSearch = new URLSearchParams(window.location.search)
    let parameters = { query: currentSearch.get("query"), filter: currentSearch.get("filter") }
    return parameters
}

Once that was done, I had to adapt the current updateQuery function to work with both. Whilst I was at it, I threw in some null checks and URI encoding to prevent filters (which can contain special characters like "&") from getting themselves confused:

const updateQuery = (query, filter) => {
    window.history.replaceState(null, null, filter && filter.length ? `?query=${encodeURIComponent(query)}&filter=${encodeURIComponent(filter).replace(/%20/g, "+")}` 
: query ? `?query=${encodeURIComponent(query)}` 
: "?")
}

Finally, I created a new function to take care of updating the filters specifically. I could probably combine this and userSearch if needed, but it's easier for me to understand if I keep them separate for now (and yes, I had to modify userSearch to mimic this layout too):

const userFilter = (filter, refine) => {
    let currentQuery = getSearch()
    refine(filter)
    updateQuery(currentQuery.query === null ? "" : currentQuery.query, filter)
}

Whether a user types in the search box or selects a filter, the result is effectively the same now. Either interaction triggers a function which calls getSearch; in turn, that determines the current URL parameters and passes them back as an array. The values of that array are then used to populate the URL itself via the updateQuery function[5].

I'm pretty sure that I could be doing all of this with either the Algolia URL/history functions or with React state, but every attempt I've made down either route has run up against issues with the various parts getting out of sync. I imagine that's my fault, but for now this will do.

That took care of saving filter choices within the URL, but what about pulling them back out? Well I'm already fetching the location.search value on page load, so it was pretty simple. I created a new variable for the filter values, and then ensured it was de-URI-encoded and split into an array. Oh, and I adapted the previous check the pulled back the query so that it removed filter information:

let urlQuery = globalHistory.location.search ? globalHistory.location.search.replace("?query=", "").replace(/&filter.*/, "") : ""

let urlFilter = globalHistory.location.search.search("&filter=") > 1 ? decodeURIComponent(globalHistory.location.search.replace(/.*&filter=/, "").replace(/\+/g, " ")).split(",") : []

Once that was done, I could just pass it through to the category list using the same defaultRefinement prop[6]. Lovely.

Clearing all search settings at once

When I first set up my search box I included a "refresh" button, but now I wanted it to clear my filters as well as the current query. Algolia have a widget (currentRefinements) that does the job and that's the easiest option, so I abstracted out my existing button into a custom version of that widget and slightly modified my original function:

const resetSearch = (refine, items) => {
    refine(items)
    updateQuery("")
}

const ClearAll = ({ items, refine }) => {
    return (
        <button onClick={() => resetSearch(refine, items)} type="button" title="Reset search">
            <span role="img" aria-label="Reset">🔄</span>
        </button>
    )
}

const ClearAllButton = connectCurrentRefinements(ClearAll)

And then, within my SearchBox component I added my new refresh button with a specific boolean attribute provided by InstantSearch:

<ClearAllButton clearsQuery />

That button now clears both the search box query and the filters on the UI, as well as updating my URL to a default blank state.

Ordering search results by date posted

The last little irritation on my list was that Algolia serves the initial Hits component in reverse date order, so the oldest posts are always visible. I wanted the inverse behaviour, as I imagine a lot of people would, which is why I thought this would be a simple thing to do. In the end, it genuinely was simple, but I can't even begin to explain all of the weird and wonderful things I tried first 😂

Here's the actual solution: log in to your Algolia console, go to Indicies -> Configuration -> Ranking and Sorting and add a new Sort By attribute. Enter the value of your date field (for me that was node.date) and select Descending from the dropdown. Done.

What you don't need to do is to try and override the initial search so that the hits variable is blank, then manually populate that same variable from a customised JSON object... I mean, it does kinda work (I should know), but it's a lot simpler to just use Algolia 🤦‍♂️

For the sake of interest though, if you do want to go the ridiculous route, here's what you need to add to your main Search component in order to override the initial search functionality:

const searchClient = {
    search(requests) {
        if (requests.every(({ params }) => !params.query)) {
            return Promise.resolve({
                results: requests.map(() => ({
                    hits: [],
                    nbHits: 0,
                    nbPages: 0,
                    processingTimeMS: 0
                }))
            })
        }
        return algoliaClient.search(requests)
    }
}

You also need to amend your existing searchClient variable to be called algoliaClient. That will suppress all search results for blank/null queries, including initial load, so pretty useful if you want a search box which only shows responses when there are some and/or when a user interacts with it.

If, however, you want to customise the initial/blank search state, you need to create a JSON object with the desired data and stick that in your hits array, like this:

const initSearchJSON = JSON.parse(
    '{"node": { "title": "", "slug": "hello-world-2","categories": ["frontend"],"tags": [],"silo": [],"contentType": "articles","sanitised": "","date": "2015-07-05T00:00:00+01:00","year": "2015","month": "July"},"_highlightResult": { "node": {   "title": { "value": "A Big Deal?", "matchLevel": "none", "matchedWords": [] }, "slug": { "value": "hello-world-2", "matchLevel": "none", "matchedWords": [] }, "contentType": { "value": "articles", "matchLevel": "none", "matchedWords": [] }, "sanitised": { "value": "So for the first time in nearly seven years I officially have a website. An extremely ugly website, admittedly, but that is largely by design - honest! Regardless, it\'s definitely a website and, […]", "matchLevel": "none", "matchedWords": []}}}}'
)

const searchClient = {
    search(requests) {
        if (requests.every(({ params }) => !params.query)) {
            return Promise.resolve({
                results: requests.map(() => ({
                    hits: [initSearchJSON],
                    nbHits: 0,
                    nbPages: 0,
                    processingTimeMS: 0
                }))
            })
        }
        return algoliaClient.search(requests)
    }
}

As I say, I wouldn't recommend doing that, but I now know it works so, in case it comes in handy in the future, I figured I'd make a note of it. Obviously, rather than hand-coding the initial JSON object you would probably want to populate it from a GraphQL query or Fetch response, but I didn't get that far down the rabbit hole before finding the simpler method (luckily). Or you could just render an entirely different component if hits is blank, but where would be the fun in that?[7]

Search Complete!

With that, I'm done... for now 😉 There are still a few additional bits and pieces I'd like to add to the search page, not least of all being a way to filter on content type (article, note, journal etc.), but for now it's working, it's tested, and it does everything I set out for it to do (and then some). That means it's time to release it, use it for a bit, and then come back to it later.

Overall, I've been really impressed with how Algolia works. There's heaps of functionality, a wide array of helper tools like InstantSearch, and detailed, well paced documentation. Plus, being able to effectively deconstruct widgets and only keep the bits you want is brilliant. I love that they allow users to get up and running very quickly, but if you want to take more control it doesn't penalise you in any way, which means you get a great user experience and a great developer experience.

I'm still not 100% comfortable with client-side rendering for something as potentially intense as search, but I need to do my own real-world testing to see how Algolia copes. If most of the actual work is done on their server, well, that's no different to any other search; if they're pushing that all to the "edge" then it's less ideal, but I'm also not sure what other options I have with the Jamstack. I do have a small niggle that there should be some way to piggyback on Craft's own search functionality, but for now I'm pretty stoked with what I've been able to bodge together 👍

Explore Other Articles

Further Reading & Sources

Conversation

Want to take part?

Comments are powered by Webmentions; if you know what that means, do your thing 👍

Footnotes

  • <p>The search page is live, the index is populated, but it all looked a bit rubbish and it didn't quite work as well as I wanted. Now it's using custom-styled components, queries are tracked/stored via the URL for persistence, and you can filter results based on category.</p>
  • Murray Champernowne.
Article permalink