Skip to main content

How To Implement Free, Fast, Local Search Using Fuse.js with Next.js SSR

· 7 min read
Dylan Huang

Banner

info

This article teaches you how to implement free, fast, and local search using Fuse.js in Next.js with SSR. If you are looking for an API docs provider with great out-of-the-box search functionality, consider using Konfig to host your API docs.

The Problem

Most websites worth its salt have a search bar. It's a great way to help users find the content they need quickly. To migrate one of our customers at Konfig from ReadMe to our Docs product, we needed to reach feature parity with ReadMe's product, which meant adding search functionality.

What we built

A fast, and local-first search bar with fuzzy search, highlighting. Oh, and it's free to host and depends on 0 external services making the experience fast.

Try it out for yourself at our customer SnapTrade's Docs.

Demo of Slick Search Functionality

How did we do this?

We used a neat open-source library called Fuse.js. It's a lightweight library that allows you to implement fuzzy search in your app.

Fuse
The Fuse.js Docs

Fuse.js has a lot of stars and a decent amount of downloads on NPM. It's also actively maintained. Great library, I highly recommend it.

GitHubNPM
GitHub Repo starsnpm

Why use Fuse.js instead of Algolia?

In our case, the requirements were as follows:

  1. Cheap/free
  2. Simple, preferably local-first/no external dependency
  3. Fast, it should feel snappy
  4. Error-prone, typos should be handled gracefully
  5. Supports custom indexing of markdown files and JSON data (for OpenAPI generated docs)
  6. Supports isolated indexing of docs for each customer as each of our customers has their own domain connected to our docs product
  7. Works with SSR (in our case we are using Incremental Static Regeneration) in Next.js

Algolia could have solved our problem and it's a great service, but it's free tier is a little limited and I was a little worried about the cost of scaling it up at $0.50 per 1,000 search requests.

Algolia Pricing
Seems like a small free-tier and potentially expensive scale up

Algolia is also not local-first. This means that you have to send your data to Algolia and then query it through their API. This is fine, but it's not ideal.

We also have one important characteristic to our search problem: the size of all indexed content can comfortably fit in the browser. This made it an even easier decision to go with Fuse.js. Furthermore, by just connecting a few dots, it was easy enough to build search functionality for our docs product so we decided to go with Fuse.js.

How to implement search functionality using Fuse.js with Next.js SSR

Before you read

Note that this tutorial assumes a couple things:

  1. You have some familiarity with Next.js and React
  2. A little TypeScript Proficiency
  3. You are using Next.js using the pages router as opposed to the new app router. If you are using the new app router, you will need to modify the code in this tutorial.
  4. You are using SSR to generate your pages at runtime

The code in this tutorial is a pseudo-code implementation of the search functionality we built for our Docs product. It is not a copy-paste solution. You should use the provided code as a guide but will need to modify the code to fit your application.

Search functionality can be broken down into three main parts:

  1. Aggregate the content to index
  2. Index the content for fast queries
  3. Render a UI to make queries on the index

Adding Fuse.js to your Next.js project

First, ensure Fuse.js is installed in your project.

yarn
npm
pnpm

_10
yarn add fuse.js

Create an SSR page in Next.js

Create a page in Next.js that uses SSR to generate the page at runtime. Here is a simple example of a page that uses ISR to generate the page at runtime.

The following SSR page includes the entire implementation for this tutorial and in subsequent sections, we will break down the implementation step-by-step.

pages/[myPage].tsx

_69
import { GetStaticPaths, GetStaticProps } from "next";
_69
import Fuse, { IFuseOptions } from "fuse.js";
_69
_69
/**
_69
* This ensures every page is generated at runtime and its blocking so that
_69
* the every page is not served until it is generated.
_69
*/
_69
export const getStaticPaths: GetStaticPaths = async () => {
_69
return {
_69
paths: [],
_69
fallback: "blocking",
_69
};
_69
};
_69
_69
/**
_69
* Fuse.js needs an array of objects to index. Include any properties you might
_69
* need to display or use in the search results.
_69
*/
_69
type SearchRecord = {
_69
id: string;
_69
title: string;
_69
content: string;
_69
};
_69
_69
/**
_69
* This function should be implemented by you.
_69
*/
_69
async function aggregateContent(): Promise<SearchRecord[]> {
_69
const content = [];
_69
// ...aggregate necessary content to index for search
_69
return content;
_69
}
_69
_69
/**
_69
* Aggregate and return the content during ISR
_69
*/
_69
export const getStaticProps: GetStaticProps<{
_69
content: SearchRecord[];
_69
}> = async () => {
_69
// Aggregate the content to index
_69
const content = await aggregateContent();
_69
return { props: { content } };
_69
};
_69
_69
function createFuseInstance(content: SearchRecord[]) {
_69
// Create the fuse instance
_69
const options = {
_69
keys: ["content", "title"],
_69
useExtendedSearch: true,
_69
ignoreLocation: true,
_69
threshold: 0.3,
_69
fieldNormWeight: 2,
_69
};
_69
return new Fuse(content, options);
_69
}
_69
_69
/**
_69
* Checkout Mantine's Spotlight component for inspiration on how to implement
_69
* search: https://mantine.dev/others/spotlight/
_69
*/
_69
function Search({ fuse }: { fuse: Fuse<SearchRecord> }) {
_69
return; // ...implement UI for search
_69
}
_69
_69
export default function MyPage({ content }: { content: SearchRecord[] }) {
_69
const fuse = createFuseInstance(content);
_69
_69
return <Search fuse={fuse} />;
_69
}

Create a data structure to represent the content to index

In our case, it was enough to simply create a type that included:

  • id - the unique subpath for this record
  • content - the content to index
  • title - the title of the content

For an explanation of indexing different types of data structures, take a look at the Fuse.js docs.

pages/[myPage].tsx

_69
import { GetStaticPaths, GetStaticProps } from "next";
_69
import Fuse, { IFuseOptions } from "fuse.js";
_69
_69
/**
_69
* This ensures every page is generated at runtime and its blocking so that
_69
* the every page is not served until it is generated.
_69
*/
_69
export const getStaticPaths: GetStaticPaths = async () => {
_69
return {
_69
paths: [],
_69
fallback: "blocking",
_69
};
_69
};
_69
_69
/**
_69
* Fuse.js needs an array of objects to index. Include any properties you might
_69
* need to display or use in the search results.
_69
*/
_69
type SearchRecord = {
_69
id: string;
_69
title: string;
_69
content: string;
_69
};
_69
_69
/**
_69
* This function should be implemented by you.
_69
*/
_69
async function aggregateContent(): Promise<SearchRecord[]> {
_69
const content = [];
_69
// ...aggregate necessary content to index for search
_69
return content;
_69
}
_69
_69
/**
_69
* Aggregate and return the content during ISR
_69
*/
_69
export const getStaticProps: GetStaticProps<{
_69
content: SearchRecord[];
_69
}> = async () => {
_69
// Aggregate the content to index
_69
const content = await aggregateContent();
_69
return { props: { content } };
_69
};
_69
_69
function createFuseInstance(content: SearchRecord[]) {
_69
// Create the fuse instance
_69
const options = {
_69
keys: ["content", "title"],
_69
useExtendedSearch: true,
_69
ignoreLocation: true,
_69
threshold: 0.3,
_69
fieldNormWeight: 2,
_69
};
_69
return new Fuse(content, options);
_69
}
_69
_69
/**
_69
* Checkout Mantine's Spotlight component for inspiration on how to implement
_69
* search: https://mantine.dev/others/spotlight/
_69
*/
_69
function Search({ fuse }: { fuse: Fuse<SearchRecord> }) {
_69
return; // ...implement UI for search
_69
}
_69
_69
export default function MyPage({ content }: { content: SearchRecord[] }) {
_69
const fuse = createFuseInstance(content);
_69
_69
return <Search fuse={fuse} />;
_69
}

Aggregate the content to index

While generating props for your page, aggregate all the content you want to index for searching in getStaticProps / getServerSideProps. For your application, you will need to decide what content you want to aggregate for searching. In our case, we wanted to index the following content:

  1. Markdown files
  2. JSON files that represent OpenAPI Specifications

The returned content should contain information from pages other than the currently rendered page. For example, if you are on the page /foo, you will want to aggregate the content from /bar and /baz as well.

Once you decide what to index, you should pull that information from whatever data source you are using. In our case, we are using the file system to store our content. In our particular case, we used the GitHub API as our Docs product uses GitHub as a CMS.

Once you have indexed your content, you can pass your SearchRecord[] along as a prop to your page.

pages/[myPage].tsx

_69
import { GetStaticPaths, GetStaticProps } from "next";
_69
import Fuse, { IFuseOptions } from "fuse.js";
_69
_69
/**
_69
* This ensures every page is generated at runtime and its blocking so that
_69
* the every page is not served until it is generated.
_69
*/
_69
export const getStaticPaths: GetStaticPaths = async () => {
_69
return {
_69
paths: [],
_69
fallback: "blocking",
_69
};
_69
};
_69
_69
/**
_69
* Fuse.js needs an array of objects to index. Include any properties you might
_69
* need to display or use in the search results.
_69
*/
_69
type SearchRecord = {
_69
id: string;
_69
title: string;
_69
content: string;
_69
};
_69
_69
/**
_69
* This function should be implemented by you.
_69
*/
_69
async function aggregateContent(): Promise<SearchRecord[]> {
_69
const content = [];
_69
// ...aggregate necessary content to index for search
_69
return content;
_69
}
_69
_69
/**
_69
* Aggregate and return the content during ISR
_69
*/
_69
export const getStaticProps: GetStaticProps<{
_69
content: SearchRecord[];
_69
}> = async () => {
_69
// Aggregate the content to index
_69
const content = await aggregateContent();
_69
return { props: { content } };
_69
};
_69
_69
function createFuseInstance(content: SearchRecord[]) {
_69
// Create the fuse instance
_69
const options = {
_69
keys: ["content", "title"],
_69
useExtendedSearch: true,
_69
ignoreLocation: true,
_69
threshold: 0.3,
_69
fieldNormWeight: 2,
_69
};
_69
return new Fuse(content, options);
_69
}
_69
_69
/**
_69
* Checkout Mantine's Spotlight component for inspiration on how to implement
_69
* search: https://mantine.dev/others/spotlight/
_69
*/
_69
function Search({ fuse }: { fuse: Fuse<SearchRecord> }) {
_69
return; // ...implement UI for search
_69
}
_69
_69
export default function MyPage({ content }: { content: SearchRecord[] }) {
_69
const fuse = createFuseInstance(content);
_69
_69
return <Search fuse={fuse} />;
_69
}

Index the content for fast queries

Let Fuse.js do the heavy lifting here.

Since all the content necessary for searching is already available in the browser, we can index the content in the browser. This is a great way to implement search functionality without having to rely on external services.

If your content is too large to pass as a part of the initial page load, you can index the content on the server and then send the index to the browser after the page loads to unblock a fast page load. If the content is too large to index on the browser, then it is worth reconsidering using an external service like Algolia.

It's incredibly easy to index content using Fuse.js. All you need to do is instantiate a new Fuse object with the content you want to index and the options you want to use for searching.

pages/[myPage].tsx

_69
import { GetStaticPaths, GetStaticProps } from "next";
_69
import Fuse, { IFuseOptions } from "fuse.js";
_69
_69
/**
_69
* This ensures every page is generated at runtime and its blocking so that
_69
* the every page is not served until it is generated.
_69
*/
_69
export const getStaticPaths: GetStaticPaths = async () => {
_69
return {
_69
paths: [],
_69
fallback: "blocking",
_69
};
_69
};
_69
_69
/**
_69
* Fuse.js needs an array of objects to index. Include any properties you might
_69
* need to display or use in the search results.
_69
*/
_69
type SearchRecord = {
_69
id: string;
_69
title: string;
_69
content: string;
_69
};
_69
_69
/**
_69
* This function should be implemented by you.
_69
*/
_69
async function aggregateContent(): Promise<SearchRecord[]> {
_69
const content = [];
_69
// ...aggregate necessary content to index for search
_69
return content;
_69
}
_69
_69
/**
_69
* Aggregate and return the content during ISR
_69
*/
_69
export const getStaticProps: GetStaticProps<{
_69
content: SearchRecord[];
_69
}> = async () => {
_69
// Aggregate the content to index
_69
const content = await aggregateContent();
_69
return { props: { content } };
_69
};
_69
_69
function createFuseInstance(content: SearchRecord[]) {
_69
// Create the fuse instance
_69
const options = {
_69
keys: ["content", "title"],
_69
useExtendedSearch: true,
_69
ignoreLocation: true,
_69
threshold: 0.3,
_69
fieldNormWeight: 2,
_69
};
_69
return new Fuse(content, options);
_69
}
_69
_69
/**
_69
* Checkout Mantine's Spotlight component for inspiration on how to implement
_69
* search: https://mantine.dev/others/spotlight/
_69
*/
_69
function Search({ fuse }: { fuse: Fuse<SearchRecord> }) {
_69
return; // ...implement UI for search
_69
}
_69
_69
export default function MyPage({ content }: { content: SearchRecord[] }) {
_69
const fuse = createFuseInstance(content);
_69
_69
return <Search fuse={fuse} />;
_69
}

Options we used for Fuse.js

See the Fuse.js docs for a full list of its options and explanations. For our product, these settings yielded the most intuitive search experience as the content property could be lengthy (hence ignoreLocation: true) and matching the title was important (hence fieldNormWeight: 2).

pages/[myPage].tsx

_69
import { GetStaticPaths, GetStaticProps } from "next";
_69
import Fuse, { IFuseOptions } from "fuse.js";
_69
_69
/**
_69
* This ensures every page is generated at runtime and its blocking so that
_69
* the every page is not served until it is generated.
_69
*/
_69
export const getStaticPaths: GetStaticPaths = async () => {
_69
return {
_69
paths: [],
_69
fallback: "blocking",
_69
};
_69
};
_69
_69
/**
_69
* Fuse.js needs an array of objects to index. Include any properties you might
_69
* need to display or use in the search results.
_69
*/
_69
type SearchRecord = {
_69
id: string;
_69
title: string;
_69
content: string;
_69
};
_69
_69
/**
_69
* This function should be implemented by you.
_69
*/
_69
async function aggregateContent(): Promise<SearchRecord[]> {
_69
const content = [];
_69
// ...aggregate necessary content to index for search
_69
return content;
_69
}
_69
_69
/**
_69
* Aggregate and return the content during ISR
_69
*/
_69
export const getStaticProps: GetStaticProps<{
_69
content: SearchRecord[];
_69
}> = async () => {
_69
// Aggregate the content to index
_69
const content = await aggregateContent();
_69
return { props: { content } };
_69
};
_69
_69
function createFuseInstance(content: SearchRecord[]) {
_69
// Create the fuse instance
_69
const options = {
_69
keys: ["content", "title"],
_69
useExtendedSearch: true,
_69
ignoreLocation: true,
_69
threshold: 0.3,
_69
fieldNormWeight: 2,
_69
};
_69
return new Fuse(content, options);
_69
}
_69
_69
/**
_69
* Checkout Mantine's Spotlight component for inspiration on how to implement
_69
* search: https://mantine.dev/others/spotlight/
_69
*/
_69
function Search({ fuse }: { fuse: Fuse<SearchRecord> }) {
_69
return; // ...implement UI for search
_69
}
_69
_69
export default function MyPage({ content }: { content: SearchRecord[] }) {
_69
const fuse = createFuseInstance(content);
_69
_69
return <Search fuse={fuse} />;
_69
}

Render a UI to make queries on the index

Now that we have indexed the content, we need to render a UI to make queries on the index. This is the fun part. You can use Fuse.js to make queries on the index and then render the results however you want.

We used Mantine's Spotlight Component which made it incredibly easy to create a good-looking search bar. We added some extra functionality for highlighting exact substring search matches as you type and configuring the CMD + K and CTRL + K keyboard shortcuts.

pages/[myPage].tsx

_69
import { GetStaticPaths, GetStaticProps } from "next";
_69
import Fuse, { IFuseOptions } from "fuse.js";
_69
_69
/**
_69
* This ensures every page is generated at runtime and its blocking so that
_69
* the every page is not served until it is generated.
_69
*/
_69
export const getStaticPaths: GetStaticPaths = async () => {
_69
return {
_69
paths: [],
_69
fallback: "blocking",
_69
};
_69
};
_69
_69
/**
_69
* Fuse.js needs an array of objects to index. Include any properties you might
_69
* need to display or use in the search results.
_69
*/
_69
type SearchRecord = {
_69
id: string;
_69
title: string;
_69
content: string;
_69
};
_69
_69
/**
_69
* This function should be implemented by you.
_69
*/
_69
async function aggregateContent(): Promise<SearchRecord[]> {
_69
const content = [];
_69
// ...aggregate necessary content to index for search
_69
return content;
_69
}
_69
_69
/**
_69
* Aggregate and return the content during ISR
_69
*/
_69
export const getStaticProps: GetStaticProps<{
_69
content: SearchRecord[];
_69
}> = async () => {
_69
// Aggregate the content to index
_69
const content = await aggregateContent();
_69
return { props: { content } };
_69
};
_69
_69
function createFuseInstance(content: SearchRecord[]) {
_69
// Create the fuse instance
_69
const options = {
_69
keys: ["content", "title"],
_69
useExtendedSearch: true,
_69
ignoreLocation: true,
_69
threshold: 0.3,
_69
fieldNormWeight: 2,
_69
};
_69
return new Fuse(content, options);
_69
}
_69
_69
/**
_69
* Checkout Mantine's Spotlight component for inspiration on how to implement
_69
* search: https://mantine.dev/others/spotlight/
_69
*/
_69
function Search({ fuse }: { fuse: Fuse<SearchRecord> }) {
_69
return; // ...implement UI for search
_69
}
_69
_69
export default function MyPage({ content }: { content: SearchRecord[] }) {
_69
const fuse = createFuseInstance(content);
_69
_69
return <Search fuse={fuse} />;
_69
}

Create an SSR page in Next.js

Create a page in Next.js that uses SSR to generate the page at runtime. Here is a simple example of a page that uses ISR to generate the page at runtime.

The following SSR page includes the entire implementation for this tutorial and in subsequent sections, we will break down the implementation step-by-step.

Create a data structure to represent the content to index

In our case, it was enough to simply create a type that included:

  • id - the unique subpath for this record
  • content - the content to index
  • title - the title of the content

For an explanation of indexing different types of data structures, take a look at the Fuse.js docs.

Aggregate the content to index

While generating props for your page, aggregate all the content you want to index for searching in getStaticProps / getServerSideProps. For your application, you will need to decide what content you want to aggregate for searching. In our case, we wanted to index the following content:

  1. Markdown files
  2. JSON files that represent OpenAPI Specifications

The returned content should contain information from pages other than the currently rendered page. For example, if you are on the page /foo, you will want to aggregate the content from /bar and /baz as well.

Once you decide what to index, you should pull that information from whatever data source you are using. In our case, we are using the file system to store our content. In our particular case, we used the GitHub API as our Docs product uses GitHub as a CMS.

Once you have indexed your content, you can pass your SearchRecord[] along as a prop to your page.

Index the content for fast queries

Let Fuse.js do the heavy lifting here.

Since all the content necessary for searching is already available in the browser, we can index the content in the browser. This is a great way to implement search functionality without having to rely on external services.

If your content is too large to pass as a part of the initial page load, you can index the content on the server and then send the index to the browser after the page loads to unblock a fast page load. If the content is too large to index on the browser, then it is worth reconsidering using an external service like Algolia.

It's incredibly easy to index content using Fuse.js. All you need to do is instantiate a new Fuse object with the content you want to index and the options you want to use for searching.

Options we used for Fuse.js

See the Fuse.js docs for a full list of its options and explanations. For our product, these settings yielded the most intuitive search experience as the content property could be lengthy (hence ignoreLocation: true) and matching the title was important (hence fieldNormWeight: 2).

Render a UI to make queries on the index

Now that we have indexed the content, we need to render a UI to make queries on the index. This is the fun part. You can use Fuse.js to make queries on the index and then render the results however you want.

We used Mantine's Spotlight Component which made it incredibly easy to create a good-looking search bar. We added some extra functionality for highlighting exact substring search matches as you type and configuring the CMD + K and CTRL + K keyboard shortcuts.

pages/[myPage].tsx

_69
import { GetStaticPaths, GetStaticProps } from "next";
_69
import Fuse, { IFuseOptions } from "fuse.js";
_69
_69
/**
_69
* This ensures every page is generated at runtime and its blocking so that
_69
* the every page is not served until it is generated.
_69
*/
_69
export const getStaticPaths: GetStaticPaths = async () => {
_69
return {
_69
paths: [],
_69
fallback: "blocking",
_69
};
_69
};
_69
_69
/**
_69
* Fuse.js needs an array of objects to index. Include any properties you might
_69
* need to display or use in the search results.
_69
*/
_69
type SearchRecord = {
_69
id: string;
_69
title: string;
_69
content: string;
_69
};
_69
_69
/**
_69
* This function should be implemented by you.
_69
*/
_69
async function aggregateContent(): Promise<SearchRecord[]> {
_69
const content = [];
_69
// ...aggregate necessary content to index for search
_69
return content;
_69
}
_69
_69
/**
_69
* Aggregate and return the content during ISR
_69
*/
_69
export const getStaticProps: GetStaticProps<{
_69
content: SearchRecord[];
_69
}> = async () => {
_69
// Aggregate the content to index
_69
const content = await aggregateContent();
_69
return { props: { content } };
_69
};
_69
_69
function createFuseInstance(content: SearchRecord[]) {
_69
// Create the fuse instance
_69
const options = {
_69
keys: ["content", "title"],
_69
useExtendedSearch: true,
_69
ignoreLocation: true,
_69
threshold: 0.3,
_69
fieldNormWeight: 2,
_69
};
_69
return new Fuse(content, options);
_69
}
_69
_69
/**
_69
* Checkout Mantine's Spotlight component for inspiration on how to implement
_69
* search: https://mantine.dev/others/spotlight/
_69
*/
_69
function Search({ fuse }: { fuse: Fuse<SearchRecord> }) {
_69
return; // ...implement UI for search
_69
}
_69
_69
export default function MyPage({ content }: { content: SearchRecord[] }) {
_69
const fuse = createFuseInstance(content);
_69
_69
return <Search fuse={fuse} />;
_69
}

Wrapping up

In this tutorial, we learned how to implement free, fast, and local search using Fuse.js with Next.js SSR. We did this by following three main steps:

  1. Aggregating all content to search
  2. Indexing the content
  3. Rendering a UI to make queries on the index.

Hopefully, this tutorial helped you understand how to add search functionality for your Next.js application. If you have any questions, feel free to reach out to me at [email protected].

Dylan Huang
Dylan HuangGitHubLinkedIn

Dylan is a Co-Founder at Konfig. Previously, he built SDK & API tooling at C3.ai. He also once built a viral website with over 210,000 unique users.