Skip to main content
Back to Blog
Tutorials
3 min read
November 23, 2024

How to Implement Search with Fuse.js in Next.js

Add client-side fuzzy search to your Next.js site with Fuse.js. Fast, lightweight search without a backend.

Ryel Banfield

Founder & Lead Developer

Fuse.js provides fuzzy search over local data. It is lightweight (< 10KB), requires no backend, and handles typos gracefully. Perfect for blog search, documentation, and product filtering.

Step 1: Install Fuse.js

pnpm add fuse.js

Step 2: Prepare Search Data

// lib/search-data.ts
export type SearchItem = {
  title: string;
  description: string;
  slug: string;
  category: string;
  tags: string[];
};

// In a real app, this comes from your CMS or build-time data
export const searchData: SearchItem[] = [
  {
    title: "Web Design for Restaurants",
    description: "Custom restaurant website design with online ordering and reservation systems.",
    slug: "/services/web-design/restaurant",
    category: "Services",
    tags: ["web design", "restaurant"],
  },
  {
    title: "How to Add Dark Mode to Next.js",
    description: "Step-by-step guide to implementing dark mode with Tailwind CSS.",
    slug: "/blog/tutorials/add-dark-mode-nextjs-tailwind-css",
    category: "Tutorials",
    tags: ["dark mode", "Tailwind CSS"],
  },
  // ... more items
];

Step 3: Configure Fuse.js

// lib/search.ts
import Fuse from "fuse.js";
import { searchData, type SearchItem } from "./search-data";

const fuseOptions: Fuse.IFuseOptions<SearchItem> = {
  keys: [
    { name: "title", weight: 0.4 },
    { name: "description", weight: 0.3 },
    { name: "tags", weight: 0.2 },
    { name: "category", weight: 0.1 },
  ],
  threshold: 0.3, // 0 = exact match, 1 = match anything
  includeMatches: true,
  includeScore: true,
  minMatchCharLength: 2,
};

export const fuse = new Fuse(searchData, fuseOptions);

export function search(query: string): Fuse.FuseResult<SearchItem>[] {
  if (!query.trim()) return [];
  return fuse.search(query, { limit: 10 });
}

Step 4: Build the Search Component

"use client";

import { useState, useRef, useEffect } from "react";
import { search } from "@/lib/search";
import type Fuse from "fuse.js";
import type { SearchItem } from "@/lib/search-data";

export function SearchDialog() {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Fuse.FuseResult<SearchItem>[]>([]);
  const [selected, setSelected] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);

  // Open with Cmd+K
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen(true);
      }
      if (e.key === "Escape") {
        setOpen(false);
      }
    }
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, []);

  // Focus input when dialog opens
  useEffect(() => {
    if (open) {
      inputRef.current?.focus();
    } else {
      setQuery("");
      setResults([]);
      setSelected(0);
    }
  }, [open]);

  // Search on query change
  useEffect(() => {
    const results = search(query);
    setResults(results);
    setSelected(0);
  }, [query]);

  // Keyboard navigation
  function handleKeyDown(e: React.KeyboardEvent) {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      setSelected((prev) => Math.min(prev + 1, results.length - 1));
    }
    if (e.key === "ArrowUp") {
      e.preventDefault();
      setSelected((prev) => Math.max(prev - 1, 0));
    }
    if (e.key === "Enter" && results[selected]) {
      window.location.href = results[selected].item.slug;
      setOpen(false);
    }
  }

  if (!open) return null;

  return (
    <div className="fixed inset-0 z-50">
      {/* Backdrop */}
      <div
        className="absolute inset-0 bg-black/50"
        onClick={() => setOpen(false)}
      />

      {/* Dialog */}
      <div className="relative mx-auto mt-[20vh] max-w-xl">
        <div className="overflow-hidden rounded-xl border bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
          {/* Input */}
          <div className="flex items-center border-b px-4 dark:border-gray-700">
            <svg
              className="h-5 w-5 text-gray-400"
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
              strokeWidth={2}
            >
              <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
            </svg>
            <input
              ref={inputRef}
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              onKeyDown={handleKeyDown}
              placeholder="Search..."
              className="w-full bg-transparent px-3 py-4 text-sm outline-none"
            />
            <kbd className="rounded border px-1.5 py-0.5 text-xs text-gray-400 dark:border-gray-600">
              Esc
            </kbd>
          </div>

          {/* Results */}
          {results.length > 0 && (
            <ul className="max-h-80 overflow-y-auto p-2">
              {results.map((result, index) => (
                <li key={result.item.slug}>
                  <a
                    href={result.item.slug}
                    className={`block rounded-lg px-3 py-2 ${
                      index === selected
                        ? "bg-blue-50 dark:bg-blue-900/20"
                        : "hover:bg-gray-50 dark:hover:bg-gray-800"
                    }`}
                    onMouseEnter={() => setSelected(index)}
                  >
                    <p className="text-sm font-medium text-gray-900 dark:text-white">
                      {result.item.title}
                    </p>
                    <p className="mt-0.5 text-xs text-gray-500">
                      {result.item.category} — {result.item.description}
                    </p>
                  </a>
                </li>
              ))}
            </ul>
          )}

          {query && results.length === 0 && (
            <div className="p-8 text-center text-sm text-gray-500">
              No results found for &ldquo;{query}&rdquo;
            </div>
          )}

          {/* Footer */}
          <div className="flex items-center justify-between border-t px-4 py-2 dark:border-gray-700">
            <div className="flex gap-2 text-xs text-gray-400">
              <span>
                <kbd className="rounded border px-1 dark:border-gray-600">↑↓</kbd> Navigate
              </span>
              <span>
                <kbd className="rounded border px-1 dark:border-gray-600">↵</kbd> Open
              </span>
            </div>
            <span className="text-xs text-gray-400">
              Powered by Fuse.js
            </span>
          </div>
        </div>
      </div>
    </div>
  );
}

Step 5: Add the Search Trigger

export function SearchButton() {
  return (
    <button
      onClick={() => {
        window.dispatchEvent(
          new KeyboardEvent("keydown", { key: "k", metaKey: true })
        );
      }}
      className="flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm text-gray-500 hover:border-gray-400 dark:border-gray-700"
    >
      <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
        <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
      </svg>
      Search
      <kbd className="rounded border px-1 text-xs dark:border-gray-600">
        ⌘K
      </kbd>
    </button>
  );
}

Step 6: Highlight Matches

function HighlightedText({
  text,
  matches,
}: {
  text: string;
  matches?: Fuse.FuseResultMatch[];
}) {
  if (!matches || matches.length === 0) return <>{text}</>;

  const titleMatch = matches.find((m) => m.key === "title");
  if (!titleMatch?.indices) return <>{text}</>;

  const parts: React.ReactNode[] = [];
  let lastIndex = 0;

  titleMatch.indices.forEach(([start, end]) => {
    if (start > lastIndex) {
      parts.push(text.slice(lastIndex, start));
    }
    parts.push(
      <mark key={start} className="bg-yellow-200 dark:bg-yellow-800">
        {text.slice(start, end + 1)}
      </mark>
    );
    lastIndex = end + 1;
  });

  if (lastIndex < text.length) {
    parts.push(text.slice(lastIndex));
  }

  return <>{parts}</>;
}

Performance Tips

  • Build the Fuse index at build time (not on every page load)
  • Debounce the search input for large datasets
  • Lazy-load the search data and Fuse.js only when the search dialog opens
  • Use limit in search options to cap results
  • For very large datasets (10,000+ items), consider server-side search

Need Search for Your Website?

We implement search solutions for websites and web applications, from simple Fuse.js to full Algolia or Elasticsearch integrations. Contact us to discuss your needs.

searchFuse.jsNext.jsfuzzy searchtutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles