What do you mean “no server”?
Lots of search features on websites rely on communicating with the server to deliver search results. For example, a user might click a
search button that sends, say, a
POST request to the server, where a
.php file handles the request, and sends the results back.
With the way we’re gonna do it here, we’re going to handle the request entirely on the client. No server-side code processing required. We’ll go over the architecture in a minute.
Why not process on the server?
Nothing wrong with the traditional way of doing it. For my website, though, I’m using Jekyll, and I’m hosting it on GitHub pages. GitHub pages doesn’t support processing with PHP, Node.js, etc. Therefore, the only way to do it is on the client (with a little bit of pre-processing, which we’ll go over in a second).
- Jekyll (a static site generator)
- jQuery (to make the AJAX stuff and displaying the results easier)
- Take advantage of Liquid (the template system that Jekyll uses) to create a JSON file of all our searchable content (in this example, blog posts)
- Use lunr.js to match the search query against all the blog posts in the JSON file and display the search results in order by the strength of the match
Step 1 — Make the JSON file
We’re going to kind of “hack” our way through Liquid to create a JSON file.
Create a new file in your root called
posts.json. Open it up:
Second, how the hell is this going to work, if it’s a JSON file? Well, you see the two sets of
--- at the top of the file? When you run
jekyll build, it will see this file as a “special file” that needs to be processed with Liquid. If we remove the
---, it won’t process the template. This is called “YAML front matter”. Any pages with YAML front matter get processed with Liquid.
Most of the templating above is self-explanatory if you understand the basics of Liquid filters. However there is one line I’d like to explain.
The content of our post might contain raw
tab characters, as well as double quotes (
"). Well, as it turns out, having
tab characters inside a JSON string is invalid JSON, so when we call our AJAX request later, nothing would be returned! Not good.
Solution? Run two
remove filters: one for the
tab character, and one for the double quotes.
Also, I want to explain this part:
If you don’t have that line of code, your output would look something like this:
See the trailing comma on the last object? This is also invalid JSON. Not good. So, we run a some Liquid that says don’t put a comma at the end if it’s the last object.
jekyll build, and you’ll end up with a compiled
_site, which contains the entire built site. Here’s the compiled JSON:
Step 2 — Send an AJAX request
Create a “Query” object
Don’t confuse our
Query object with
Query object will serve as a container for everything related to our search. I’ve commented the code so you can see what everything does.
Whew! That’s a lot of stuff. Let’s write a little API documentation to show you what everything does:
var query = new Query()— we can create a new “container” to hold our search query
query.goToLocation('my-search-page')— will bring us to
query.setFromURL()— when we reached
query.getJSON('/posts.json')— this just grabs our page,
/posts.jsonand returns the return value of
$.get(this is useful because we can call
Still confused? I recommend reading up first on Immediately Invoked Function Expressions.
Let the user search for something
I hate forms. But here, we use them for a very specific reason. It’s so that we can execute our search function both whenever the user clicks the “Search” button, or whenever the user hits the “enter” key on the keyboard. HTML has this built-in functionality. If we didn’t use a
form, and just used, say, a
div, we would have to write code that would listen to both the
click event on the button, and the
keydown event on the text box.
We could easily write the above code as such:
However, the only reason we’re using our custom Query object is because it separates concerns, and we’ve also created a reusable, easy-to-read and understandable module.
Finally, send the request
We’ll have the following code on our
console.log(data)in the above code). If you don't see it, your JSON might be improperly formatted.
utils is a little package of a function that we’ll call
shade. This function will be used to color our results based on the strength of the match against our query.
We’ve simply wrapped it in a module, because later on we could add more methods to module (if we wanted to). For example, a custom forEach function.
Step 3 — Use lunr.js and display the results
Here comes the fun part. First, here’s the HTML for our
Let’s go over this in pieces:
We set up a
searchIndex object, which is just an initialization of
lunr. If you notice, we call
this.field(), and every field actually exactly matches the fields that we have in our
Then, we loop through our JSON objects from
posts.json, and we add them to
.search(query.get()) on our
searchIndex. Remember, we called
query.setFromURL(), so when we call
query.get(), it returns the query string from the URL
It turns out that the
results object only contains objects with a
ref field. Open up your console and run your code and you’ll see what I mean. The
ref field we set up to be the URL of the post. So all we’re gonna do is add the title of each post to the result object too, so that way later, we can add the
a tags with the URLs and titles.
Each object also has a field called
score, which lunr.js generates. This is a number between 0 and 1, which reflects the strength of the match. So if we have a match with a score of
0.09, and a match with a score of
0.0062, the one with
0.09 matched higher, based on lunr’s algorithm.
So, we’ll use a little bit of math. If we have two matches, result #1 at
0.09 and result #2 at
0.0062, the total is
0.0962, right? So result #1’s fraction of the total is
result.score / totalScore, which is about
0.9355, and result #2’s is about
In comes our
shade method. We’re gonna add a thick border to the side of each search result, and we’ll darken it by each result’s percentage. Thus, the higher strength of the match for a result, the darker the side border is, which shows the user, intuitively, that that specific match is “stronger”, since it has a “stronger” color. As a side note, when we loop through the elements and display them in a list with jQuery, the results are in order from highest score to lowest score by default (thanks to lunr.js), so the results are automatically ordered from high to low in your resulting HTML.
So that’s it. We’ve successfully implemented a client-side only (or, mostly, since we use AJAX) search system.
- We can use it on GitHub pages, or whatever hosting site you use that doesn’t support a backend with PHP, Node.js, etc.
- We can render all the JSON ourselves (with Liquid and Jekyll), and serve it up statically
- … It works.
- Could be slow, depending on how many results you have. If you have a lot of results, you might want to consider using some loading icons or some sort of AJAX progress bar to show the progress of the loading so the user isn’t looking at a blank screen thinking nothing is happening. Also, you could display only a certain amount of results at a time, and wait to render to the second or third, etc., set of results until the user clicks a “next” button, or whatever.
- JSON is finnicky. You have to render your JSON file with Liquid very detailed.
- It’s a little bit hacky (using Liquid to make the JSON file)
As of the date of this post, I am using this on my website. Scroll down to the footer to utilize the search feature.
Thanks to christian-fei for the inspiration for the JSON creation via Liquid.
Also, thanks Lunr.js!