Adding Elasticlunr Search to my Next.js Blog

Posted in Programming

User Experience

The user experience consists of three things:

  • A search bar on the navbar that is visible from all pages.
  • Using the search bar shows a handful of the top results below it.
  • Clicking one of the results navigates to the blog post for it.

You can see this in action by using the search bar at the top of this page.

The Search API

Search results are fetched from a REST API at /api/search?q=[search term].

I decided to separate search behind an API so that I can change how search is performed in the future (e.g. query an ElasticSearch server) without needing to change anything in the UI.

Creating the Elasticlunr Search Index

I have a function in lib/search.ts that creates a JSON search index of all the posts.

A blog post's slug (e.g. add-search-to-nextjs-blog) is what uniquely identifies each blog post in the search index.

import elasticlunr from 'elasticlunr';
import {getPosts, filterPosts} from "./posts";
export async function createSearchIndex() {
const index = elasticlunr()
index.addField('title');
index.addField('subtitle');
index.addField('excerpt');
index.addField('thumbnailDescription');
index.addField('categories');
index.addField('author');
index.setRef('slug');
const allPosts = await getPosts();
const posts = filterPosts(allPosts, {published: true, before: new Date()});
posts.forEach((post) => {
index.addDoc(post);
})
return index.toJSON();
}

Search API Handler

In pages/api/search.ts, the Elasticlunr search index is created from the JSON obtained from lib/search.ts. The index is only created the first time the API endpoint is called if it doesn't exist since blog posts don't change on a deployed site.

import {createSearchIndex} from "../../lib/search";
import elasticlunr from "elasticlunr";
let searchIndex;
function enhanceSearchResult(result, searchIndex) {
const {title, thumbnail} = searchIndex.documentStore.getDoc(result.ref);
return {title, thumbnail, ...result};
}
export default async function handler(req, res) {
const env = process.env.NODE_ENV;
const {q, max=5} = req.query;
// Make sure we're creating/loading the search index every time during development, so it reflects any changes
// made to the blog posts.
if (!searchIndex || env === "development") {
const idx = await createSearchIndex();
searchIndex = elasticlunr.Index.load(idx);
}
let searchResults = searchIndex.search(q, {});
let excluded = 0;
if (max > 0) {
const n = searchResults.length - max;
excluded = n > 0 ? n : 0;
searchResults = searchResults.slice(0, max);
}
searchResults = searchResults.map(result => enhanceSearchResult(result, searchIndex))
res.status(200).json({
results: searchResults,
excluded,
});
}

React Components

SearchContextProvider

SearchContextProvider in components/search/searchContextProvider.tsx uses useState to store the search term and a function to update the search term. It creates a context using react's Context API to allow multiple components to access the search term and update function.

import React, {useState} from "react";
export const Context = React.createContext(undefined);
export default function SearchContextProvider({children}) {
const [searchTerm, setSearchTerm] = useState();
return (
<Context.Provider value={{searchTerm, setSearchTerm}}>
{children}
</Context.Provider>
)
}

Any components that are children of SearchContextProvider will be able to access the search term and update function from the Context.

SearchForm

The search form in components/search/searchForm.tsx uses setSearchTerm from the context to update the search term whenever the user submits a new search.

import {useContext, useState} from "react";
import {Context as SearchContext} from "./searchContextProvider";
export default function SearchForm() {
const {setSearchTerm} = useContext(SearchContext);
const [inputValue, setInputValue] = useState('');
const handleChange = e => {
setInputValue(e.target.value);
}
const handleClick = e => {
setSearchTerm(inputValue);
}
const handleKeyDown = e => {
if (e.key === "Enter") {
handleClick(e);
}
}
return (
<div className="input-group flex flex-row">
<input type="search"
className="form-control relative flex-auto min-w-0 block w-full px-3 py-1.5 text-base font-normal text-gray-700 bg-white bg-clip-padding border border-solid border-gray-300 rounded transition ease-in-out m-0 focus:text-gray-700 focus:bg-white focus:border-blue-600 focus:outline-none"
onChange={handleChange}
onKeyDown={handleKeyDown}
value={inputValue}
placeholder="Search"
aria-label="Search"
aria-describedby="search-button"/>
<button type="button"
className="btn inline-block px-6 py-2.5 bg-blue-500 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out flex items-center"
onClick={handleClick}
id="search-button">
<svg aria-hidden="true"
focusable="false"
data-prefix="fas"
data-icon="search"
className="w-4"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512">
<path fill="currentColor"
d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"></path>
</svg>
</button>
</div>
)
}

SearchResults

The SearchResults component in components/search/searchResults.tsx queries the search API whenever the search term changes, and displays links to the blog posts.

import {useContext, useEffect, useState} from 'react';
import {Context as SearchContext} from "./searchContextProvider";
import Link from 'next/link';
import Thumbnail from "../thumbnail";
const max = 5;
export default function SearchResults() {
const [searchResults, setSearchResults] = useState({results: [], excluded: 0});
const [isLoading, setIsLoading] = useState(false);
const {searchTerm} = useContext(SearchContext);
// Call the search API whenever the search term changes.
useEffect(() => {
setIsLoading(true);
fetch(`/api/search?q=${encodeURIComponent(searchTerm)}&max=${max}`)
.then(res => res.json())
.then(data => {
setSearchResults(data);
})
.finally(() => {
setIsLoading(false);
});
}, [searchTerm]);
if (isLoading) return <p>Loading...</p>
return (
<div className="search-results max-w-6xl w-full">
<ul>
{searchResults.results.map(result => {
return (
<li key={result.ref} className="p-2 md:p-5">
<Link href={`/blog/${result.ref}`}>
<a className="flex flex-row items-center">
<Thumbnail className="inline-block mr-3" thumbnail={result.thumbnail} width={32} height={32}/> {result.title}
</a>
</Link>
</li>
)
})}
</ul>
{searchResults.excluded > 0 && <p className="text-center text-sm italic">{`Showing ${max} of ${max+ searchResults.excluded} search results`}</p>}
</div>
)
}

Layout

The Layout in components/layout.tsx is used in every Page, and nests the NavBar (which contains the SearchForm) and SearchResults within the SearchContextProvider.

import classNames from "classnames";
import Head from "next/head";
import Navbar from "./navbar";
import Footer from "./footer";
import SearchContextProvider from "./search/searchContextProvider";
import SearchResults from "./search/searchResults";
interface LayoutProps {
siteTitle: string;
className?: string;
children?: JSX.Element | JSX.Element[];
}
export default function Layout({siteTitle, className, children}: LayoutProps) {
return (
<div className={classNames('layout', className)}>
<Head>
<title>{siteTitle}</title>
<link rel="icon" href="/favicon.ico"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta
name="description"
content="Eric Turner's website"
/>
<meta name="og:title" content={siteTitle}/>
</Head>
<SearchContextProvider>
<Navbar/>
<section className="search-results flex flex-col items-center">
<SearchResults />
</section>
{children}
<Footer/>
</SearchContextProvider>
</div>
)
}

Full Source Code

You can view all of the source code for my blog on Github .