cómo implementar búsqueda instantanea con Fuse.js en NextJS
guía completa para implementar un sistema de búsqueda fuzzy rápido y eficiente usando Fuse.js en tu aplicación NextJS. Incluye ejemplos prácticos y mejores prácticas.
cómo implementar búsqueda instantanea con Fuse.js en NextJS
Si tu sitio NextJS tiene contenido que los usuarios necesitan encontrar rápidamente, necesitas un buen sistema de búsqueda. Fuse.js te permite implementar una búsqueda instantanea y tolerante a errores directamente en el navegador, sin depender de servicios externos. En esta guía vas a aprender a configurar fuse.js en NextJS desde cero, con ejemplos que puedes copiar y adaptar a tu proyecto.
¿Qué es Fuse.js?
Fuse.js es una librería de JavaScript liviana para hacer búsqueda difusa (en ingles 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. Puedes revisar la documentación oficial de Fuse.js para ver todas las opciones disponibles.
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 que se busca y como
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 catalogos
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.jsVerifica 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 = [
{ título: 'El Quijote', autor: 'Miguel de Cervantes' },
{ título: 'Cien años de soledad', autor: 'Gabriel Garcia Marquez' },
{ título: 'La sombra del viento', autor: 'Carlos Ruiz Zafon' },
]
// Crear instancia de Fuse
const fuse = new Fuse(libros, {
keys: ['título', 'autor']
})
// Buscar
const resultado = fuse.search('garsia')
console.log(resultado)
// Encuentra "Gabriel Garcia Marquez" aunque "Garcia" este mal escritoFuse.js encontro "Garcia" aunque lo escribimos "garsia". 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: ['título', 'descripción', 'tags'],
// threshold (umbral): que 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 que 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 que campos buscar:
// búsqueda simple
keys: ['título']
// múltiples campos
keys: ['título', 'descripción', 'autor']
// Con pesos (mayor peso = más importante)
keys: [
{ name: 'título', weight: 2 }, // El título es más importante
{ name: 'descripción', 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 - Que tan estricta es la búsqueda (Umbral de tolerancia)
Controla que 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 // Mas 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 mayoria de casos0.5 - 0.6: Tolerante, puede dar resultados no esperados0.7+: Demasiado tolerante
distance - Distancia entre caracteres
Define que tan lejos pueden estar los caracteres coincidentes.
distance: 100 // Default, bueno para textos cortos
distance: 1000 // Mejor para textos largosincludeScore - Ver puntuación de relevancia
útil para entender que tan buena es cada coincidencia o para debugging (depuración):
includeScore: true
// Resultado incluye:
{
item: { título: '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 Cmd+K (o Ctrl+K) desde cualquier página y buscar instantaneamente.
Caracteristicas avanzadas
Resaltar coincidencias
Muestra que parte del texto coincidio con la búsqueda:
const fuse = new Fuse(datos, {
includeMatches: true, // Habilitar matches
keys: ['título', 'descripción']
})
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('Indices 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 categorías = ['React', 'NextJS']
const filtradoMultiple = resultado.filter(({ item }) =>
categorías.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. Usa setTimeout para implementarlo.
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 ejecutara 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 indices grandes
Si tu índice es muy grande y cambia dinamicamente, usa useMemo para evitar recrearlo en cada renderizado del componente. Si necesitas repasar como funcionan useEffect, useMemo y otros hooks, revisa la guía de ciclo de vida en React.
import { useMemo } from 'react'
function Search() {
const fuse = useMemo(() => {
return new Fuse(searchIndex, options)
}, []) // El array vacio [] 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
Si vas a consultar datos desde una API, Asegúrate de manejar correctamente las llamadas asíncronas. Puedes revisar la guía de async/await en JavaScript para entender el patron.
// 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', descripción: 'Crea un nuevo usuario', atajo: 'Ctrl+U' },
{ nombre: 'borrar usuario', descripción: 'Elimina un usuario existente', atajo: 'Ctrl+D' },
{ nombre: 'editar perfil', descripción: 'Modifica información del perfil', atajo: 'Ctrl+E' },
]
const fuse = new Fuse(comandos, {
keys: ['nombre', 'descripción', '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',
descripción: 'Smartphone de última generación',
marca: 'Apple',
precio: 1299,
tags: ['movil', '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: 'descripción', 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. Manten 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
Asegúrate de que tu índice de búsqueda solo contenga datos publicos. Si manejas datos de usuarios, válida la estructura con Zod antes de exponerlos.
// Malo - incluye datos privados
const searchIndex = [
{
title: 'Usuario Admin',
email: 'admin@ejemplo.com', // Información sensible
password: '...', // Nunca hagas esto!
}
]
// Bueno - solo datos publicos
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.54. 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 estes buscando en las keys correctas:
keys: ['title', 'description'] // Asegúrate que incluye los campos correctos- Verifica que los datos existan:
console.log(searchIndex) // Debugbú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 // Mas 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()
Conclusion
Fuse.js es una excelente opción para agregar búsqueda instantanea 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
Cuando NO usar Fuse.js:
- Tienes millones de registros
- Necesitas búsqueda en tiempo real desde DB
- Requieres análisis semántico complejo
Para la mayoria de blogs, documentación y sitios medianos, Fuse.js es la solución perfecta: simple, rápida y efectiva. Combinala con un buen sitemap automatico en NextJS para que Google descubra tu contenido y Fuse.js lo haga accesible para tus usuarios.
Recursos adicionales
Preguntas frecuentes
¿Qué es la búsqueda fuzzy y por qué es útil en aplicaciones web?
La búsqueda fuzzy (o difusa) es un tipo de búsqueda que encuentra resultados incluso cuando el usuario comete errores de escritura o no recuerda el término exacto. Es útil porque mejora la experiencia del usuario al ser tolerante a typos y variaciones en la forma de escribir.
¿Cómo se configura el threshold de Fuse.js para obtener buenos resultados?
El threshold controla que tan estricta es la búsqueda, con valores entre 0.0 (solo coincidencias exactas) y 1.0 (todo coincide). Un valor de 0.3 a 0.4 es el balance ideal para la mayoria de casos. Puedes ajustarlo según tu necesidad: más bajo para búsquedas de código, más alto para texto general.
¿Fuse.js afecta el rendimiento de mi aplicación NextJS?
Fuse.js es muy ligero (3KB comprimido) y rápido para hasta 10,000 items. Para optimizar rendimiento, crea el índice una sola vez fuera del componente, usa debouncing para evitar búsquedas en cada tecla, y limita los resultados mostrados con slice.
Articulos relacionados
Zod Avanzado: Discriminated Unions, Transforms y Pipes
Patrones avanzados de Zod: discriminated unions, transforms, pipes, preprocess, y como validar datos complejos en TypeScript con schemas reutilizables.
tRPC + Next.js: APIs Type-Safe sin REST
Implementa tRPC en Next.js para APIs 100% type-safe. Sin schemas de API, sin fetch manual, sin types duplicados. End-to-end type safety con TypeScript.
Webhooks en Next.js: Recibe y Procesa Eventos
Implementa webhooks en Next.js para recibir eventos de Stripe, GitHub, Clerk y otros servicios. Verificación de firmas, tipado y manejo de errores.