Cómo implementar búsqueda instantánea con Fuse.js en NextJS
Publicado el 30 de septiembre, 2025 • 15 min de lectura
Si tu sitio tiene contenido que los usuarios necesitan encontrar rápidamente, necesitas un buen sistema de búsqueda. En esta guía aprenderás a implementar una búsqueda instantánea y tolerante a errores usando Fuse.js en NextJS.
¿Qué es Fuse.js?
Fuse.js es una librería de JavaScript liviana y poderosa para hacer búsqueda difusa (en inglés fuzzy search). A diferencia de una búsqueda exacta, la búsqueda difusa encuentra resultados incluso cuando el usuario comete errores al escribir o no recuerda exactamente lo que busca.
Ejemplo práctico
Si buscas "nxtjs routr", Fuse.js puede encontrar "NextJS Router" porque entiende que probablemente quisiste escribir eso.
¿Por qué usar Fuse.js?
Ventajas:
- Tolerante a errores: Encuentra resultados aunque haya errores al escribir
- Rápido: Búsqueda en milisegundos, todo del lado del cliente (en el navegador)
- Sin servidor necesario: No necesitas un servidor de búsqueda como Algolia
- Ligero: Solo 3KB comprimido
- Flexible: Control total sobre qué se busca y cómo
Casos de uso ideales:
- Búsqueda en documentación
- Búsqueda de productos en e-commerce pequeño/mediano
- Búsqueda en blogs
- Autocompletado de comandos
- Búsqueda en catálogos
Fuse.js es perfecto para hasta ~10,000 items. Si tienes más datos, considera soluciones como Algolia o Elasticsearch.
Instalación
Instala Fuse.js
npm install fuse.js
# o
yarn add fuse.js
# o
pnpm add fuse.js
Verifica la instalación
Revisa tu package.json
y deberías ver:
{
"dependencies": {
"fuse.js": "^7.1.0"
}
}
Tu primera búsqueda con Fuse.js
Empecemos con un ejemplo simple para entender cómo funciona.
Ejemplo básico
import Fuse from 'fuse.js'
// Datos de ejemplo
const libros = [
{ titulo: 'El Quijote', autor: 'Miguel de Cervantes' },
{ titulo: 'Cien años de soledad', autor: 'Gabriel García Márquez' },
{ titulo: 'La sombra del viento', autor: 'Carlos Ruiz Zafón' },
]
// Crear instancia de Fuse
const fuse = new Fuse(libros, {
keys: ['titulo', 'autor']
})
// Buscar
const resultado = fuse.search('garsía')
console.log(resultado)
// Encuentra "Gabriel García Márquez" aunque "García" esté mal escrito
Fuse.js encontró "García" aunque lo escribimos "garsía". ¡Eso es búsqueda fuzzy!
Configuración de Fuse.js
Fuse.js tiene muchas opciones de configuración. Veamos las más importantes.
Opciones principales
const fuse = new Fuse(datos, {
// Campos donde buscar
keys: ['titulo', 'descripcion', 'tags'],
// threshold (umbral): qué tan estricta es la búsqueda
// 0.0 = solo coincidencias exactas, 1.0 = todo coincide
threshold: 0.3,
// Distancia máxima entre caracteres coincidentes
distance: 100,
// Incluir puntuación de relevancia en resultados
includeScore: true,
// Incluir qué partes del texto coincidieron
includeMatches: true,
// Longitud mínima de búsqueda antes de empezar a buscar
minMatchCharLength: 2,
})
Explicación de opciones críticas
keys
- Campos de búsqueda
Define en qué campos buscar:
// Búsqueda simple
keys: ['titulo']
// Múltiples campos
keys: ['titulo', 'descripcion', 'autor']
// Con pesos (mayor peso = más importante)
keys: [
{ name: 'titulo', weight: 2 }, // El título es más importante
{ name: 'descripcion', weight: 1 },
{ name: 'tags', weight: 0.5 }, // Los tags menos importantes
]
Usa pesos cuando algunos campos son más relevantes que otros. Por ejemplo, coincidencias en el título son más importantes que en la descripción.
threshold
- Qué tan estricta es la búsqueda (Umbral de tolerancia)
Controla qué tan similar debe ser el texto para considerarse una coincidencia. Es un valor entre 0.0 y 1.0.
threshold: 0.0 // Solo coincidencias exactas
threshold: 0.3 // Balance recomendado (el valor por defecto es 0.6)
threshold: 0.6 // Más tolerante a errores
threshold: 1.0 // Todo coincide (no es útil)
Guía práctica:
0.0 - 0.2
: Muy estricto, casi exacto0.3 - 0.4
: Balance ideal para la mayoría de casos ⭐0.5 - 0.6
: Tolerante, puede dar resultados no esperados0.7+
: Demasiado tolerante
distance
- Distancia entre caracteres
Define qué tan lejos pueden estar los caracteres coincidentes.
distance: 100 // Default, bueno para textos cortos
distance: 1000 // Mejor para textos largos
includeScore
- Ver puntuación de relevancia
Útil para entender qué tan buena es cada coincidencia o para debugging (depuración):
includeScore: true
// Resultado incluye:
{
item: { titulo: 'NextJS' },
score: 0.001 // Menor puntuación = mejor coincidencia
}
Implementando búsqueda en NextJS
Vamos a crear un sistema de búsqueda completo para un blog o documentación.
Paso 1: Crear el índice de búsqueda
Primero, necesitas un archivo con todos los datos para buscar.
// lib/search-index.ts
export interface SearchItem {
id: string
title: string
description: string
url: string
category: string
keywords: string[]
}
export const searchIndex: SearchItem[] = [
{
id: 'nextjs-intro',
title: 'Introducción a NextJS',
description: 'Aprende los fundamentos de NextJS y cómo crear tu primera aplicación',
url: '/docs/nextjs/intro',
category: 'NextJS',
keywords: ['nextjs', 'react', 'framework', 'introducción'],
},
{
id: 'react-hooks',
title: 'React Hooks explicados',
description: 'Guía completa de useState, useEffect y otros hooks de React',
url: '/docs/react/hooks',
category: 'React',
keywords: ['react', 'hooks', 'useState', 'useEffect'],
},
{
id: 'typescript-basics',
title: 'TypeScript para principiantes',
description: 'Conceptos básicos de TypeScript que todo desarrollador debe conocer',
url: '/docs/typescript/basics',
category: 'TypeScript',
keywords: ['typescript', 'tipos', 'javascript'],
},
// Agrega más items...
]
Para proyectos grandes, puedes generar este índice automáticamente leyendo tus archivos MDX o consultando tu base de datos.
Paso 2: Crear el componente de búsqueda
// components/Search.tsx
"use client"
import { useState, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import Fuse from 'fuse.js'
import { searchIndex, SearchItem } from '@/lib/search-index'
// Configurar Fuse
const fuse = new Fuse(searchIndex, {
keys: [
{ name: 'title', weight: 2 },
{ name: 'description', weight: 1.5 },
{ name: 'keywords', weight: 1 },
{ name: 'category', weight: 0.5 },
],
threshold: 0.3,
includeScore: true,
})
export default function Search() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchItem[]>([])
const [isOpen, setIsOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const router = useRouter()
// Manejar búsqueda
const handleSearch = (searchQuery: string) => {
setQuery(searchQuery)
if (searchQuery.trim() === '') {
setResults([])
return
}
const searchResults = fuse.search(searchQuery)
const items = searchResults.slice(0, 8).map((result) => result.item)
setResults(items)
}
// Abrir con Cmd+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setIsOpen(true)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
// Focus cuando se abre
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus()
}
}, [isOpen])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] px-4">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/50"
onClick={() => setIsOpen(false)}
/>
{/* Modal */}
<div className="relative w-full max-w-2xl bg-white rounded-lg shadow-2xl">
{/* Input */}
<div className="p-4 border-b">
<input
ref={inputRef}
type="text"
placeholder="Buscar..."
value={query}
onChange={(e) => handleSearch(e.target.value)}
className="w-full text-lg outline-none"
/>
</div>
{/* Resultados */}
<div className="max-h-[60vh] overflow-y-auto p-2">
{results.length > 0 ? (
results.map((result) => (
<button
key={result.id}
onClick={() => {
router.push(result.url)
setIsOpen(false)
}}
className="w-full text-left p-3 hover:bg-gray-100 rounded"
>
<div className="font-medium">{result.title}</div>
<div className="text-sm text-gray-600">{result.description}</div>
<div className="text-xs text-gray-400 mt-1">{result.category}</div>
</button>
))
) : query ? (
<div className="p-8 text-center text-gray-500">
No se encontraron resultados
</div>
) : (
<div className="p-8 text-center text-gray-500">
Escribe para buscar...
</div>
)}
</div>
</div>
</div>
)
}
Paso 3: Agregar el componente al layout
// app/layout.tsx
import Search from '@/components/Search'
export default function RootLayout({ children }) {
return (
<html>
<body>
<Search />
{children}
</body>
</html>
)
}
Ahora puedes presionar ⌘K
(o Ctrl+K
) desde cualquier página y buscar instantáneamente.
Características avanzadas
Resaltar coincidencias
Muestra qué parte del texto coincidió con la búsqueda:
const fuse = new Fuse(datos, {
includeMatches: true, // Habilitar matches
keys: ['titulo', 'descripcion']
})
const resultado = fuse.search('nextjs')
// resultado contiene información de matches
resultado.forEach(({ item, matches }) => {
matches?.forEach((match) => {
console.log('Campo:', match.key)
console.log('Índices coincidentes:', match.indices)
// Puedes usar esto para resaltar texto
})
})
Componente para resaltar texto
// components/HighlightText.tsx
interface Props {
text: string
indices: [number, number][]
}
export default function HighlightText({ text, indices }: Props) {
if (!indices || indices.length === 0) {
return <span>{text}</span>
}
const highlighted: JSX.Element[] = []
let lastIndex = 0
indices.forEach(([start, end]) => {
// Texto antes del match
if (start > lastIndex) {
highlighted.push(
<span key={`text-${lastIndex}`}>
{text.substring(lastIndex, start)}
</span>
)
}
// Texto coincidente (resaltado)
highlighted.push(
<mark key={`match-${start}`} className="bg-yellow-200">
{text.substring(start, end + 1)}
</mark>
)
lastIndex = end + 1
})
// Texto después del último match
if (lastIndex < text.length) {
highlighted.push(
<span key={`text-${lastIndex}`}>
{text.substring(lastIndex)}
</span>
)
}
return <>{highlighted}</>
}
Búsqueda con ordenamiento personalizado
Puedes ordenar resultados por score o criterios personalizados:
const resultado = fuse.search('nextjs')
// Ordenar por score (mejor primero)
const ordenado = resultado.sort((a, b) => (a.score || 0) - (b.score || 0))
// Ordenar por categoría y luego por score
const ordenadoCustom = resultado.sort((a, b) => {
// Priorizar categoría "NextJS"
if (a.item.category === 'NextJS' && b.item.category !== 'NextJS') return -1
if (a.item.category !== 'NextJS' && b.item.category === 'NextJS') return 1
// Si son la misma categoría, ordenar por score
return (a.score || 0) - (b.score || 0)
})
Filtros combinados
Combina búsqueda fuzzy con filtros exactos:
const resultado = fuse.search('hooks')
// Filtrar solo resultados de categoría "React"
const filtrado = resultado.filter(({ item }) => item.category === 'React')
// O filtrar por múltiples categorías
const categorias = ['React', 'NextJS']
const filtradoMultiple = resultado.filter(({ item }) =>
categorias.includes(item.category)
)
Optimización de rendimiento
1. Crear índice una sola vez
// ❌ Malo - crea índice en cada render
function Search() {
const fuse = new Fuse(searchIndex, options)
// ...
}
// ✅ Bueno - crea índice una vez fuera del componente
const fuse = new Fuse(searchIndex, options)
function Search() {
// usa 'fuse' directamente
}
2. Retrasar la búsqueda para optimizar rendimiento
Evita buscar en cada tecla que el usuario presiona. En su lugar, espera a que termine de escribir. Esta técnica se llama debouncing (anti-rebote) y mejora significativamente el rendimiento.
import { useState, useEffect } from 'react'
function Search() {
const [query, setQuery] = useState('')
const [queryRetrasada, setQueryRetrasada] = useState('')
// Esperar 300ms después de que el usuario deje de escribir
useEffect(() => {
const timer = setTimeout(() => {
setQueryRetrasada(query)
}, 300)
// Limpiar el timer si el usuario sigue escribiendo
return () => clearTimeout(timer)
}, [query])
// Buscar solo cuando queryRetrasada cambia (es decir, después del retraso)
useEffect(() => {
if (queryRetrasada) {
const results = fuse.search(queryRetrasada)
// Actualizar resultados
}
}, [queryRetrasada])
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Escribe para buscar..."
/>
)
}
Con esta técnica, si el usuario escribe "NextJS" rápidamente, solo se ejecutará UNA búsqueda en lugar de 6 (una por cada letra). Esto ahorra recursos y hace la búsqueda mucho más fluida.
3. Limitar resultados
No muestres miles de resultados:
const resultado = fuse.search(query)
// Limitar a 10 resultados
const limitado = resultado.slice(0, 10)
4. Usar useMemo para índices grandes
Si tu índice es muy grande y cambia dinámicamente, usa useMemo
para evitar recrearlo en cada renderizado del componente:
import { useMemo } from 'react'
function Search() {
const fuse = useMemo(() => {
return new Fuse(searchIndex, options)
}, []) // El array vacío [] significa "crear solo una vez"
// ...
}
useMemo
es un hook de React que memoriza (guarda) el resultado de una operación costosa. Aquí lo usamos para crear el índice de Fuse una sola vez y reutilizarlo, en lugar de crearlo cada vez que el componente se renderiza.
Generando índice automáticamente
Para sitios grandes, genera el índice automáticamente:
Desde archivos MDX
// scripts/generate-search-index.ts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
interface SearchItem {
id: string
title: string
description: string
url: string
category: string
}
function generateSearchIndex() {
const docsDir = path.join(process.cwd(), 'content', 'docs')
const searchIndex: SearchItem[] = []
function scanDirectory(dir: string, category: string) {
const files = fs.readdirSync(dir)
files.forEach((file) => {
const filePath = path.join(dir, file)
const stat = fs.statSync(filePath)
if (stat.isDirectory()) {
scanDirectory(filePath, file)
} else if (file.endsWith('.mdx')) {
const content = fs.readFileSync(filePath, 'utf-8')
const { data } = matter(content)
searchIndex.push({
id: file.replace('.mdx', ''),
title: data.title || '',
description: data.description || '',
url: `/docs/${category}/${file.replace('.mdx', '')}`,
category: category,
})
}
})
}
scanDirectory(docsDir, 'docs')
// Escribir índice
fs.writeFileSync(
path.join(process.cwd(), 'lib', 'search-index.json'),
JSON.stringify(searchIndex, null, 2)
)
console.log(`✅ Generados ${searchIndex.length} items para búsqueda`)
}
generateSearchIndex()
Ejecuta el script:
// package.json
{
"scripts": {
"generate-search": "tsx scripts/generate-search-index.ts",
"build": "npm run generate-search && next build"
}
}
Desde base de datos
// lib/generate-search-index.ts
import { db } from './database'
export async function generateSearchIndex() {
const posts = await db.post.findMany({
where: { published: true },
select: {
id: true,
title: true,
excerpt: true,
slug: true,
category: true,
tags: true,
},
})
return posts.map((post) => ({
id: post.id,
title: post.title,
description: post.excerpt,
url: `/blog/${post.slug}`,
category: post.category,
keywords: post.tags,
}))
}
Casos de uso reales
1. Búsqueda de comandos
Para una aplicación con muchos comandos:
const comandos = [
{ nombre: 'crear usuario', descripcion: 'Crea un nuevo usuario', atajo: 'Ctrl+U' },
{ nombre: 'borrar usuario', descripcion: 'Elimina un usuario existente', atajo: 'Ctrl+D' },
{ nombre: 'editar perfil', descripcion: 'Modifica información del perfil', atajo: 'Ctrl+E' },
]
const fuse = new Fuse(comandos, {
keys: ['nombre', 'descripcion', 'atajo'],
threshold: 0.4,
})
// Buscar "crer usario" → encuentra "crear usuario"
2. Búsqueda de productos
Para un e-commerce pequeño:
const productos = [
{
nombre: 'iPhone 15 Pro',
descripcion: 'Smartphone de última generación',
marca: 'Apple',
precio: 1299,
tags: ['móvil', 'smartphone', 'apple']
},
// ...más productos
]
const fuse = new Fuse(productos, {
keys: [
{ name: 'nombre', weight: 2 },
{ name: 'marca', weight: 1.5 },
{ name: 'tags', weight: 1 },
{ name: 'descripcion', weight: 0.5 },
],
threshold: 0.3,
})
3. Autocompletado
function Autocomplete() {
const [query, setQuery] = useState('')
const [suggestions, setSuggestions] = useState<string[]>([])
const handleChange = (value: string) => {
setQuery(value)
if (value.length < 2) {
setSuggestions([])
return
}
const results = fuse.search(value)
const titles = results.slice(0, 5).map((r) => r.item.title)
setSuggestions(titles)
}
return (
<div>
<input value={query} onChange={(e) => handleChange(e.target.value)} />
{suggestions.length > 0 && (
<ul>
{suggestions.map((suggestion, i) => (
<li key={i} onClick={() => setQuery(suggestion)}>
{suggestion}
</li>
))}
</ul>
)}
</div>
)
}
Mejores prácticas
1. Mantén el índice actualizado
// Actualizar índice cuando agregues contenido
const nuevoItem = {
id: 'nuevo-post',
title: 'Mi nuevo post',
description: '...',
}
searchIndex.push(nuevoItem)
// Recrear Fuse con el índice actualizado
const fuse = new Fuse(searchIndex, options)
2. No incluyas contenido sensible
// ❌ Malo - incluye datos privados
const searchIndex = [
{
title: 'Usuario Admin',
email: 'admin@ejemplo.com', // Información sensible
password: '...', // ¡Nunca hagas esto!
}
]
// ✅ Bueno - solo datos públicos
const searchIndex = [
{
title: 'Documentación de API',
description: 'Aprende a usar nuestra API',
}
]
3. Optimiza el threshold según tu caso
// Para búsqueda de código (más estricto)
threshold: 0.2
// Para búsqueda de texto general (balanceado)
threshold: 0.3
// Para búsqueda muy tolerante
threshold: 0.5
4. Usa paginación para muchos resultados
const RESULTS_PER_PAGE = 10
function SearchResults() {
const [page, setPage] = useState(0)
const results = fuse.search(query)
const paginatedResults = results.slice(
page * RESULTS_PER_PAGE,
(page + 1) * RESULTS_PER_PAGE
)
return (
<div>
{paginatedResults.map((result) => (
<ResultItem key={result.item.id} item={result.item} />
))}
<button onClick={() => setPage(page + 1)}>
Cargar más
</button>
</div>
)
}
Troubleshooting común
No encuentra resultados obvios
Problema: Buscas "NextJS" pero no encuentra nada.
Soluciones:
- Verifica que el threshold no sea muy bajo:
threshold: 0.3 // Prueba con 0.4 o 0.5
- Revisa que estés buscando en las keys correctas:
keys: ['title', 'description'] // Asegúrate que incluye los campos correctos
- Verifica que los datos existan:
console.log(searchIndex) // Debug
Búsqueda muy lenta
Problema: La búsqueda tarda varios segundos.
Soluciones:
- Limita el tamaño del índice
- Usa debouncing
- Limita resultados con
.slice(0, 10)
- No recrees Fuse en cada render
Resultados irrelevantes
Problema: Encuentra cosas que no tienen sentido.
Soluciones:
- Baja el threshold:
threshold: 0.2 // Más estricto
- Ajusta pesos de campos:
keys: [
{ name: 'title', weight: 3 }, // Mayor peso = más importante
{ name: 'description', weight: 1 },
]
Alternativas a Fuse.js
Si Fuse.js no se ajusta a tu caso:
Para búsquedas más grandes:
- Algolia - Servicio de búsqueda en la nube
- MeiliSearch - Motor de búsqueda open-source
- Elasticsearch - Para aplicaciones enterprise
Para búsquedas más simples:
.filter()
nativo de JavaScript- Simple string matching con
.includes()
Conclusión
Fuse.js es una excelente opción para agregar búsqueda instantánea y tolerante a errores en tu sitio NextJS:
Cuándo usar Fuse.js:
- ✅ Tienes menos de 10,000 items
- ✅ Quieres búsqueda del lado del cliente
- ✅ No quieres pagar por servicios externos
- ✅ Necesitas tolerancia a typos
Cuándo NO usar Fuse.js:
- ❌ Tienes millones de registros
- ❌ Necesitas búsqueda en tiempo real desde DB
- ❌ Requieres análisis semántico complejo
Para la mayoría de blogs, documentación y sitios medianos, Fuse.js es la solución perfecta: simple, rápida y efectiva.
Recursos adicionales
¿Implementaste búsqueda con Fuse.js en tu proyecto? Comparte tu experiencia en los comentarios.
Sobre el autor: Rod Alexanderson es desarrollador web especializado en NextJS y React. Crea documentación técnica en español para ayudar a la comunidad de desarrolladores en Latinoamérica.