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 “{query}”
</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
limitin 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.