Implementing Site Search with Fuse.js in Next.js 14


July 1, 2024 Program

Implementing Site Search with Fuse.js in Next.js 14
This tutorial demonstrates how to implement a site search feature in a Next.js project using Fuse.js, including pre-building a JSON file and dynamically displaying content based on user input.

Why Use Fuzzy Search

🔗

The most basic logic to implement a search feature is to directly match the input string and return the corresponding data. However, this is not very user-friendly in practice. It's common to make spelling mistakes or to only remember part of a word or phrase, which would result in no search results. Using fuzzy search can effectively solve this problem by helping to identify synonyms and variants, making the search more flexible. This is why I choose Fuse.js. The details of the fuzzy search algorithm are beyond the scope of this article.

Implementing Fuzzy Search

🔗

Installing Fuse.js

🔗
sh
npm install fuse.js

How to Use Fuse.js

🔗

Example:

JS
import Fuse from "fuse.js";
import React, { useState } from "react";
import searchData from '@/lib/search-data.json';

const posts = searchData;
const [input, setInput] = useState<any[]>([]);

const handleSearch = (event: any) => {
  const { value } = event.target;
    
  const fuse = new Fuse(posts, {
    keys: ["title", "description"],
  });

  const results = fuse.search(value);
  const items = results.map((result) => result.item);
  setInput(items);
};

How to use:

  1. Create all searchable data const posts = searchData;
    Data is in JSON format Example:
Json
[
  {
    "id": "Implementing Multilingual i18n with Next.js 14",
    "title": "Implementing Multilingual i18n with Next.js 14",
    "slug": "tutorials/nextjs-i18n",
    "date": "2024-06-22T23:44:38.000Z",
    "draft": false,
    "description": "Implementing Multilingual i18n with Next.js 14 and negotiator.",
    "thumbnail": "img/thumbnail/056en.webp",
    "categories": [
      "Program"
    ],
    "tags": [
      "Next.js",
      "React",
      "Web"
    ],
    "lang": "en"
  }
]
  1. Set the fields to search keys: ["title", "description"]
  2. Insert the search query const results = fuse.search(value);
  3. You can then get the search results, which is very easy to use.

Below are the implementation steps:

1. Add a search input box in the navigation bar

🔗

Navigation bar: Navbar.tsx, some code has been omitted, please refer mainly to the {/* Search bar */} section.

JS
"use client"
import { Input, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@nextui-org/react";
import { SearchIcon } from "./SearchIcon";
import Search from "./Search";

export default function App({ params: { lang, layouti18n, categories, tags } }: {
    params: { lang: Locale, layouti18n: any, categories: any, tags: any }
}) {
    // Code omitted
    const { isOpen, onOpen, onClose, onOpenChange } = useDisclosure();
    return (
        <Navbar className="bg-background text-foreground transition-colors duration-700 opacity-95" isMenuOpen={isMenuOpen} onMenuOpenChange={setIsMenuOpen}>
            {/* Code omitted */}          
            <NavbarContent justify="end">    
                {/* Search bar */}
                <Input
                    classNames={{
                        base: "hidden sm:block max-w-full sm:max-w-[10rem] h-10",
                        mainWrapper: "h-full",
                        input: "text-small",
                        inputWrapper: "h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20",
                    }}
                    placeholder="Type to search..."
                    size="sm"
                    startContent={<SearchIcon size={18} width={undefined} height={undefined} />}
                    type="search"
                    onClick={onOpen}
                />

                <Modal className="dark:bg-gray-400"
                    isOpen={isOpen}
                    onOpenChange={onOpenChange}
                    size="2xl" backdrop="blur">
                    <ModalContent>
                        {(onClose) => (
                            <>
                                <ModalHeader className="flex flex-col gap-1">{layouti18n.sitesearch}</ModalHeader>
                                <ModalBody>
                                    <Search params={{ lang: lang, layout: layouti18n, onClose: onClose }} />
                                </ModalBody>
                                <ModalFooter>
                                </ModalFooter>
                            </>
                        )}
                    </ModalContent>
                </Modal>
            </NavbarContent>   
        </Navbar>
    );
}

Detailed Explanation:

The main idea is to create a search input field in the navigation bar, which will pop up a Modal window when clicked. You can modify it according to your preferences.

2. Search Functionality Component

🔗

Search.tsx

JS
"use client"
import React, { useState } from "react";
import Fuse from "fuse.js";
import searchData from '@/lib/search-data.json';
import { Locale } from "../../../../i18n.config";
import Image from "next/image";
import Link from "next/link";
import { Input, ScrollShadow } from "@nextui-org/react";
import { SearchIcon } from "./SearchIcon";

export default function Search({ params: { lang, layout, onClose } }: {
  params: { lang: Locale, layout: any, onClose: any }
}) {
  let posts = searchData;
  posts = posts.filter((post: any) => post.lang === lang && post.draft === false);
  const [input, setInput] = useState<any[]>([]);

  const handleSearch = (event: any) => {
    const { value } = event.target;

    const fuse = new Fuse(posts, {
      keys: ["title", "description"],
    });

    const results = fuse.search(value);
    const items = results.map((result) => result.item);
    setInput(items);
  };
  return (
    <div>
      <Input
        onChange={handleSearch}
        // label="Search"
        isClearable
        radius="lg"
        classNames={{
          label: "text-black/50 dark:text-white/90",
          input: [
            "text-xl",
            "bg-transparent",
            "text-black/90 dark:text-white/90",
            "placeholder:text-default-700/50 dark:placeholder:text-white/60",
          ],
          innerWrapper: "bg-transparent",
          inputWrapper: [
            "text-xl",
            "shadow-xl",
            "bg-default-200/50",
            "dark:bg-default/60",
            "backdrop-blur-xl",
            "backdrop-saturate-200",
            "hover:bg-default-200/70",
            "dark:hover:bg-default/70",
            "group-data-[focus=true]:bg-default-200/50",
            "dark:group-data-[focus=true]:bg-default/60",
            "!cursor-text",
          ],
        }}
        placeholder="Type to search..."
        startContent={
          <SearchIcon className="text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0" />
        }
      />
      <table>
        <tbody>
          <ScrollShadow hideScrollBar className="w-full h-96 mt-4">
            <ul className="space-y-4 max-w-screen-sm w-full">
              {input.map(({ id, title, date, draft, thumbnail, categories, tags, slug, filePath, description }: any, index: number) => (
                <li key={index} className="border-b border-gray-200 pb-4 flex flex-col md:flex-row md:min-h-40 relative transition-transform duration-300 transform hover:-translate-y-1">
                  {thumbnail &&
                    <div className="min-w-full md:min-w-48 h-auto md:w-48 md:h-32 ">
                      <Link href={'/' + lang + '/' + slug} onClick={onClose}>
                        <Image src={'/' + thumbnail} width={500}
                          height={500} alt={title} className="w-full h-52 md:w-44 md:h-full rounded-md" />
                      </Link>
                    </div>
                  }
                  <div className="flex-grow">
                    <div className="min-h-28">
                      <h2 className="text-lg font-normal text-textcolor break-normal md:line-clamp-2">
                        <Link href={'/' + lang + '/' + slug} onClick={onClose}>
                          {title}
                        </Link>
                      </h2>
                      <div className="flex flex-wrap text-sm justify-start">
                        <p className="flex items-center text-gray-500 mr-4">
                          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="size-6 mr-2">
                            <path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
                          </svg>
                          {
                            date instanceof Date ?
                              date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) :
                              new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
                          }
                        </p>
                        <p className="flex items-center text-gray-500">
                          <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="size-6 mr-2">
                            <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" />
                          </svg>
                          {categories.map((category: string, index: number) => (
                            <span key={index} className="inline-block">
                              {category}{index < categories.length - 1 ? ' ,' : ''}
                            </span>
                          ))}
                        </p>
                      </div>
                      <p className="text-base text-gray-500 line-clamp-2">{description}</p>
                    </div>
                    <div className="text-right"> {/* Apply absolute positioning here */}
                      <Link href={'/' + lang + '/' + slug} className="p-0" onClick={onClose}>
                        <button role="link" className="relative after:absolute after:bottom-0 after:left-0 after:h-[0.5px] after:w-full after:origin-bottom-left after:scale-x-100 after:bg-neutral-800 after:transition-transform after:duration-150 after:ease-in-out hover:after:origin-bottom-right hover:after:scale-x-0 dark:after:bg-white">
                          {layout.readmore}
                        </button>
                      </Link>
                    </div>
                  </div>

                </li>
              ))}
            </ul>
          </ScrollShadow>
        </tbody>
      </table>
    </div>
  );
}

Detailed Explanation:

  1. The searchable article data has been pre-created import searchData from '@/lib/search-data.json';, so you just need to import it directly.
  2. import { Locale } from "../../../../i18n.config"; This project has multilingual functionality, which can be ignored if not needed.
  3. Import nextui and the search hint Icon, which can be adjusted according to your preference or choose a different UI library.
JS
import { Input, ScrollShadow } from "@nextui-org/react";
import { SearchIcon } from "./SearchIcon"; 

SearchIcon.jsx

JS
import React from "react";
export const SearchIcon = ({
  size = 24,
  strokeWidth = 1.5,
  width,
  height,
  ...props
}) => (
  <svg
    aria-hidden="true"
    fill="none"
    focusable="false"
    height={height || size}
    role="presentation"
    viewBox="0 0 24 24"
    width={width || size}
    {...props}
  >
    <path
      d="M11.5 21C16.7467 21 21 16.7467 21 11.5C21 6.25329 16.7467 2 11.5 2C6.25329 2 2 6.25329 2 11.5C2 16.7467 6.25329 21 11.5 21Z"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth={strokeWidth}
    />
    <path
      d="M22 22L20 20"
      stroke="currentColor"
      strokeLinecap="round"
      strokeLinejoin="round"
      strokeWidth={strokeWidth}
    />
  </svg>
);

Conclusion

🔗

Demonstration

🔗

With the above steps, you can easily implement a search functionality on your website. This article mainly records the process of using Fuse.js. Since the code also includes other features of a personal project, including multilingual support and the use of a UI library, please be understanding if it's not easy to read.

Originally, the old website used Algolia's search functionality, which had limitations on the number of searches. Now, by switching to Fuse.js, we can easily overcome the challenges of fuzzy searching.

Reference

Next.jsReactWeb



Avatar

Alvin

Software engineer, interested in financial knowledge, health concepts, psychology, independent travel, and system design.

Related Posts