Te voy a contar un problema con el que encontré a medida que el blog crecía. Tenía ya tropecientos posts sobre patrones de diseño, TypeScript, Linux, Docker y demas historias, y cada vez que quería buscar un post concreto… tocaba scrollear como un loco por la home o tirar de Ctrl+F en el navegador a ver si colaba y así no se puede vivir.
El problema es que este blog es estático, está hecho con Astro, no tiene backend, no tiene base de datos, es HTML servido desde un hosting. Entonces, ¿cómo metes un buscador en algo que es básicamente un montón de archivos .html? Pos resulta que se puede y además queda de locos.

1. El problema: buscar en un sitio estático
Un sitio estático no tiene servidor al que mandarle una query tipo SELECT * FROM posts WHERE titulo LIKE '%facade%'. Todo se genera en build time build time El momento en que Astro compila tu proyecto y genera los archivos HTML/CSS/JS estáticos que se subirán al servidor. y lo que llega al navegador es HTML plano, entonces, ¿qué opciones hay para meter una funcionalidad de búsqueda?
Potentes pero necesitas un servicio de terceros, configurar API keys, posible coste mensual y una dependencia externa, overkill para un blog personal.
Generas un JSON con todo el contenido en build y lo cargas entero en el cliente. Funciona, pero el usuario descarga TODO el índice aunque busque una sola palabra, demasiado.
Full-text search en el navegador. Más potente que Fuse.js pero más pesado. Tiene plugin para Astro pero añade bastante bundle.
Indexa tu contenido en build time, genera chunks del índice que se cargan bajo demanda. Solo 6KB inicial. Sin servidor, sin API, sin coste. Perfecto.
Aparentemente 💡 Pagefind Librería de búsqueda estática que indexa tu sitio en build time y genera un índice fragmentado que se carga bajo demanda en el cliente. Zero config, zero coste, ~6KB de carga inicial. Más info → es la opción perfecta para un blog estático, vamos a ver por qué.
2. ¿Qué es Pagefind y por qué mola tanto?
Pagefind es una herramienta que escanea tu carpeta de archivos HTML generados en el dist/ de Astro y crea un índice de búsqueda fragmentado. La gracia de Pagefind es que no carga todo el índice de golpe en el navegador sino que solo descarga los fragmentos que necesita según lo que el usuario busque.
El JavaScript del buscador pesa unos 6KB comprimido. Los fragmentos del índice se cargan bajo demanda cuando el usuario escribe.
Literalmente un comando después del build y ea. Detecta el idioma automáticamente, genera el índice, y te da una API para buscar.
Todo funciona en el cliente. El índice son archivos estáticos que se sirven junto con tu web. Cero coste de infraestructura adicional.
Lo mejor es que soporta español de forma nativa. Detecta el lang="es" de tu HTML y aplica las reglas de stemming correctas. Esto quiere decir que si buscas buscando, encontrará buscador también.
3. Instalando Pagefind
Lo primero es instalar Pagefind como dependencia de desarrollo:
Ahora hay que decirle al build de Astro que después de generar los HTML, ejecute Pagefind para indexarlos. Abre tu package.json y modifica el script de build:
{
"scripts": {
"dev": "astro dev",
"build": "astro build && npx pagefind --site dist",
"preview": "astro preview"
}
}
Presta atención al &&. Primero se ejecuta astro build que genera los HTML en dist/ y después npx pagefind --site dist que escanea esos HTML y genera el índice en dist/pagefind/. Es importante el orden porque si Pagefind se ejecuta antes que Astro, no hay HTML que indexar y no encuentra nada.
Amo a probarlo:
57 páginas indexadas, 9697 palabras en español, en menos de 2 segundos. Pagefind ha generado una carpeta dist/pagefind/ con el JavaScript del buscador y los fragmentos del índice, es bastante rápido.
4. Controlando qué se indexa con data-pagefind-body
Pagefind indexa todo el contenido de todas las páginas por defecto, esto incluye la navegación, el footer, las sidebars… que no es lo que queremos. Si alguien busca home no debería aparecer la página del patrón Facade solo porque el menú de navegación tiene un enlace a home.
Para que no curra esto Pagefind tiene un atributo mágico: data-pagefind-body. Cuando lo pones en cualquier elemento de tu sitio, Pagefind cambia de modo y solo indexa el contenido dentro de elementos con ese atributo, las páginas que no lo tengan se ignoran.
En Astro esto se traduce en añadirlo al <main> de tu layout de posts:
<!-- src/layouts/PostLayout.astro -->
<main class="flex-grow" data-pagefind-body>
<slot />
</main>
Con esto le estás diciendo a Pagefind que solo me interesa indexar el contenido de los posts, pasa de la nav, del footer y de todo lo demás. Si tienes más layouts y páginas, añádelo donde tenga sentido.
También puedes excluir las partes que tu quieras dentro del contenido indexado con data-pagefind-ignore:
<!-- Esto NO se indexará aunque esté dentro de data-pagefind-body -->
<div data-pagefind-ignore>
<nav>menú que no quiero indexar</nav>
</div>
Ojito con esto porque si usas data-pagefind-body en alguna página, todas las páginas que no lo tengan serán ignoradas, es todo o nada.
5. Creando el componente de búsqueda
Pagefind te da una API JavaScript para buscar, pero tú controlas la UI al 100%, así que podrás implementar el diseño custom que tu quieras.
Yo en el blog he implementado un modal que se abre al pulsar un botón o Ctrl+K, con un input de búsqueda y resultados en tiempo real, vamos a crearlo paso a paso.
La estructura HTML del modal
Creamos src/components/SearchModal.astro:
<div id="search-modal" class="fixed inset-0 z-[10000] hidden" data-pagefind-ignore>
<!-- Backdrop oscuro -->
<div id="search-backdrop" class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<!-- Modal centrado -->
<div class="relative mx-auto mt-[10vh] w-full max-w-2xl px-4">
<div class="overflow-hidden rounded-xl border border-gray-700 bg-gray-800 shadow-2xl">
<!-- Input de búsqueda -->
<div class="flex items-center border-b border-gray-700 px-4">
<svg class="h-5 w-5 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<input
id="search-input"
type="text"
placeholder="Buscar posts..."
class="w-full bg-transparent px-4 py-4 text-gray-200 placeholder-gray-500 outline-none"
autocomplete="off"
/>
<kbd class="hidden shrink-0 rounded border border-gray-600 px-2 py-0.5 text-xs text-gray-500 sm:inline">
ESC
</kbd>
</div>
<!-- Contenedor de resultados -->
<div id="search-results" class="max-h-[60vh] overflow-y-auto p-2">
<div id="search-placeholder" class="px-4 py-8 text-center text-sm text-gray-500">
Escribe para buscar en el blog...
</div>
</div>
</div>
</div>
</div>
Puntos clave del HTML:
z-[10000]para que esté por encima de tododata-pagefind-ignorepara que el propio modal no se indexehiddende inicio, se muestra con JavaScript- Backdrop con
bg-black/60ybackdrop-blur-smpara ese efecto elegante de fondo difuminado - El
<kbd>ESC</kbd>es un detallito visual que le dice al usuario cómo cerrar
El JavaScript que le da vida
Aquí es donde conectamos con la API de Pagefind, añade esto en el mismo componente, debajo del HTML:
<script is:inline>
if (!window.__searchInit) {
window.__searchInit = true;
var modal = document.getElementById('search-modal');
var backdrop = document.getElementById('search-backdrop');
var input = document.getElementById('search-input');
var resultsContainer = document.getElementById('search-results');
var placeholder = document.getElementById('search-placeholder');
var pagefind = null;
var debounceTimer = null;
// Carga Pagefind de forma lazy (solo cuando se abre el buscador)
async function loadPagefind() {
if (pagefind) return pagefind;
try {
pagefind = await import('/pagefind/pagefind.js');
await pagefind.init();
return pagefind;
} catch (_e) {
return null;
}
}
function openSearch() {
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
input.value = '';
resultsContainer.innerHTML = '';
placeholder.textContent = 'Escribe para buscar en el blog...';
resultsContainer.appendChild(placeholder);
setTimeout(function () {
input.focus();
}, 50);
loadPagefind(); // Pre-carga mientras el usuario piensa qué buscar
}
function closeSearch() {
modal.classList.add('hidden');
document.body.style.overflow = '';
}
// Event delegation: cualquier elemento con .search-trigger abre el modal
document.addEventListener('click', function (e) {
if (e.target.closest('.search-trigger')) {
e.preventDefault();
openSearch();
}
});
// Atajos de teclado
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
openSearch();
}
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
closeSearch();
}
});
// Cerrar al hacer clic en el backdrop
backdrop.addEventListener('click', closeSearch);
// Búsqueda con debounce de 200ms
input.addEventListener('input', function () {
clearTimeout(debounceTimer);
var query = input.value.trim();
if (!query) {
resultsContainer.innerHTML = '';
placeholder.textContent = 'Escribe para buscar en el blog...';
resultsContainer.appendChild(placeholder);
return;
}
debounceTimer = setTimeout(async function () {
var pf = await loadPagefind();
if (!pf) {
resultsContainer.innerHTML = '';
placeholder.textContent = 'Buscador no disponible en modo desarrollo.';
resultsContainer.appendChild(placeholder);
return;
}
var search = await pf.search(query);
if (search.results.length === 0) {
resultsContainer.innerHTML = '';
placeholder.textContent = 'Sin resultados para \u201C' + query + '\u201D';
resultsContainer.appendChild(placeholder);
return;
}
resultsContainer.innerHTML = '';
// Mostramos máximo 8 resultados
var toShow = search.results.slice(0, 8);
for (var i = 0; i < toShow.length; i++) {
(async function (result) {
var data = await result.data();
var link = document.createElement('a');
link.href = data.url;
link.className = 'block rounded-lg px-4 py-3 transition-colors hover:bg-gray-700/50';
link.innerHTML =
'<div class="font-medium text-gray-200">' +
(data.meta.title || 'Sin título') +
'</div>' +
'<div class="mt-1 text-sm text-gray-400 line-clamp-2">' +
(data.excerpt || '') +
'</div>';
link.addEventListener('click', closeSearch);
resultsContainer.appendChild(link);
})(toShow[i]);
}
}, 200);
});
}
</script>
Vamos a repasar lo destacable de este código porque aquí hay chicha:
¿Por qué is:inline?
En Astro, los scripts normales se bundlean bundlean Se procesan, optimizan y empaquetan junto con el resto del JavaScript del proyecto. Esto significa que se ejecutan después de que el DOM esté listo, no de forma bloqueante. y se ejecutan como módulos. Pero nuestro buscador necesita estar disponible inmediatamente en el DOM, sin esperar a que Astro procese nada. Con is:inline el script se inyecta tal cual en el HTML, como hacíamos en los tiempos del jQuery.
¿Por qué el guard window.__searchInit?
Si este componente se incluye en varios layouts como en nuestro caso, el script podría ejecutarse más de una vez y registrar listeners duplicados. El guard previene que si ya se ha iniciado no lo inicialice otra vez.
¿Por qué event delegation con .search-trigger?
Porque el botón de búsqueda aparece dos veces en la página, en la nav de escritorio y en la de móvil. En lugar de buscar cada botón por ID, usamos 💡 event delegation Patrón de JavaScript donde registras un solo listener en el document y compruebas si el click vino de un elemento con cierta clase. Así funciona sin importar cuántas instancias del botón haya en la página.
Más info →
: un solo listener en document que comprueba si el click vino de un .search-trigger.
¿Por qué import() dinámico para Pagefind?
Porque Pagefind no existe en modo desarrollo (npm run dev), solo se genera con npm run build. Si hiciéramos un import estático, el dev server petaría al no encontrar el archivo. Con el import() dinámico y el try/catch mostramos un mensaje friendly si no está disponible.
¿Por qué debounce de 200ms?
Sin debounce cada tecla que el usuario pulse dispararía una búsqueda. Si el usuario escribe “facade” a velocidad normal, eso son 6 búsquedas consecutivas de las cuales solo la última es útil. El debounce debounce Técnica que retrasa la ejecución de una función hasta que el usuario deje de interactuar durante un tiempo determinado. Evita ejecuciones innecesarias. espera 200ms después de la última tecla antes de buscar, suficiente para que el usuario termine de escribir pero lo bastante rápido para que se sienta instantáneo.
6. Añadiendo el botón de búsqueda al header
El componente ya funciona, pero necesita un botón que lo dispare. Vamos a añadir un icono de lupa al header, junto a la navbar y el toggle de dark & light mode.
Edita tu componente de header (en este blog es HeaderElements.astro):
<!-- Versión desktop: entre la navegación y el theme toggle -->
<div class="hidden items-center gap-2 md:flex">
<Navigation />
<button
type="button"
aria-label="Buscar"
class="search-trigger inline-flex items-center justify-center rounded-md p-1.5 text-gray-400 hover:bg-gray-700 hover:text-white focus:outline-none"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</button>
<ThemeToggle />
</div>
Fíjate en que la clase es search-trigger que coincide con lo que buscamos en el event delegation del JavaScript. El estilo es idéntico al del ThemeToggle con los mismos paddings, hover y colores para que quede visualmente integrado.
Haz lo mismo para la versión móvil, añadiendo otro botón con la misma clase search-trigger en la zona del menú hamburguesa. Como usamos event delegation, da igual cuántos botones haya: todos disparan el mismo modal.
7. Incluyendo el modal en los layouts
El último paso es incluir el componente SearchModal en tus layouts. Como queremos que esté disponible en todas las páginas, lo añadimos a cada layout justo antes de cerrar el </body>:
---
import SearchModal from '../components/SearchModal.astro';
import SeasonalDecoration from '../components/SeasonalDecoration.astro';
// ... resto de imports
---
<body>
<!-- ... tu header, main, footer... -->
<SearchModal />
<SeasonalDecoration />
</body>
Si tienes varios layouts (BaseLayout, PostLayout, PageLayout, ReviewLayout…), repite la importación en cada uno. El guard window.__searchInit se encarga de que aunque el componente se renderice, el JavaScript solo se ejecute una vez.
Estos pasos ya dependen de tu diseño, en un buen diseño se va a colocar en un lugar y casi que ya estaría.
8. Probándolo en local
Aquí hay un detallito importante: en modo desarrollo (npm run dev) el buscador no funciona porque Pagefind genera el índice solo con el build. El archivo /pagefind/pagefind.js no existe hasta que ejecutas npm run build.
Para probarlo en local:
Ahora abre http://localhost:4321, pulsa Ctrl+K o haz clic en la lupa del header, escribe “facade” y ahí tienes los resultados instantáneos con excerpts resaltados, es una pasada.
9. Cómo funciona por debajo: el flujo completo
Para que quede claro el ciclo de vida de una búsqueda con Pagefind:
astro build genera los HTML. Después, pagefind --site dist escanea esos HTML, extrae el texto de los elementos con data-pagefind-body y genera un índice fragmentado en dist/pagefind/.
El usuario visita tu web. El JavaScript de Pagefind (~6KB) se carga de forma lazy solo cuando abre el buscador por primera vez. El índice NO se carga todavía.
El usuario escribe "facade". Pagefind descarga solo los fragmentos del índice que contienen esa palabra. No todo el índice, solo lo necesario.
Pagefind devuelve URLs, títulos y excerpts con las palabras resaltadas. Tú los renderizas en tu UI. Cada resultado se resuelve como una Promise independiente.
10. Comparativa con otras opciones
Para que veas por qué Pagefind es la opción más razonable para un blog estático:
| Característica | Fuse.js | Algolia | Pagefind |
|---|---|---|---|
| Coste | Gratis | Gratis hasta X búsquedas/mes | Gratis siempre |
| Necesita servidor | No | Sí (externo) | No |
| Carga inicial | Todo el JSON (~50-500KB) | ~20KB SDK | ~6KB |
| Idioma español | No (fuzzy genérico) | Sí | Sí (stemming nativo) |
| Resultados con excerpt | No | Sí | Sí (con highlight) |
| Setup | Manual (generar JSON) | API keys + dashboard | Un comando |
| Escala con contenido | Mal (todo en memoria) | Bien | Bien (chunks) |
11. Errores comunes
El índice de Pagefind solo existe después de npm run build. En npm run dev no hay índice y el buscador no funcionará. Usa npm run build && npm run preview para probar.
Sin este atributo, Pagefind indexa TODO: nav, footer, sidebars... Los resultados incluirán ruido de la interfaz y no solo contenido real.
Si usas un script normal de Astro, el import('/pagefind/pagefind.js') fallará en build porque Vite intenta resolverlo como dependencia. Con is:inline el import dinámico se ejecuta en el navegador.
Si incluyes el componente en 4 layouts y no usas el guard window.__searchInit, el listener del input se registrará 4 veces y harás 4 búsquedas por cada tecla. Usa siempre el patrón guard.
Vamos concluyendo…
Lo que acabamos de montar es un buscador completo para un sitio estático sin servidor, sin APIs externas, sin coste mensual y con un impacto de apenas 6KB en la carga inicial. Pagefind hace todo el trabajo pesado en build time y el navegador solo carga los trocitos del índice que necesita.
Lo genial de esta solución es que escala sin sufrir. Da igual que tengas 10 posts o 1000 porque Pagefind fragmenta el índice y solo envía lo necesario. Y como el índice se regenera con cada build, siempre está actualizado sin que tengas que hacer nada extra.
Si tienes un blog estático con Astro, Hugo, Eleventy o cualquier generador y no tienes buscador, esto se implementa en media horita y el resultado es un UX que parece de aplicación con backend.
EA, nos vemos en los bares 🍻
Pon a prueba lo aprendido
1. ¿En qué momento genera Pagefind el índice de búsqueda?
2. ¿Para qué sirve el atributo data-pagefind-body?
3. ¿Por qué se usa is:inline en el script del buscador?
4. ¿Cuánto pesa aproximadamente el JavaScript inicial de Pagefind?
5. ¿Qué hace el patrón guard window.__searchInit?
6. ¿Qué pasa si usas data-pagefind-body en un layout pero no en otros?