A fur monster looking at the camera. the image is in pixel art.

Add instant search to a static site

A showcase of Pagefind and how to integrate it with a static site.

This article will be a 4 min read.

This blog post was last updated on: 8/21/2023.

I wanted to add a search parameter inside of the blog so that you could find anything fast, either by looking at the

What is Pagefind?

Pagefind is a fully static search library that aims to perform well on large sites, while using as little of your users’ bandwidth as possible, and without hosting any infrastructure.

One benefit of using pagefind is that we can run them inside our hosting environment without relying on a third-party service, like Algolia, Meilisearch or, Typesense.

That being said, it’s super simple to use.

Install Pagefind

Install pagefind as a development dependency. To make simpler our npm scipt to run the actual JS lookup

Install pagefind
npm install --save-dev pagefind
pnpm install --save-dev pagefind
yarn add --dev pagefind

Create a script to run pagefind

Pagefind needs to run after your site has been built. Add a script to your package.json to run pagefind after building your site.

package.json
{
"scripts": {
"build": "astro build && npm run generate-search",
"generate-search": "pagefind --source dist/client --bundle-dir pagefind"
}
}

Create the “base data”

An issue I ran into so I could style it is “How do I get some of the results to be highlighted?“. The solution is simple: build the site and copy the pagefind data into your public directory.

Copy the pagefind data
npm run build && cp -r dist/client/pagefind public/
Don’t forget to add public/pagefind to the .gitignore

There is probably a better way to create this. But this will work the same for development and production.

Now the fun part of this post. Let’s keep the search bar simple and add it to the Header component. The component will have a button to open a modal with the search bar. The modal will have a list of results populated by pagefind.

src/components/Header.astro
<button id="button"> Search</button>
<dialog
id="modal"
class="min-h-16 fixed top-0 w-9/12 max-w-xl rounded-[var(--rounded-btn)] p-5"
>
<label for="search">Search...</label>
<input
id="search"
type="text"
class="input w-full transition-all focus:input-primary"
/>
<div id="results" class="overflow-y-auto"></div>
<div aria-label="modal controls" class="flex gap-4">
<div>
<kbd class="kbd kbd-sm">esc</kbd>
<span>Leave</span>
</div>
</div>
</dialog>
<style>
@import 'https://www.nerdfonts.com/assets/css/webfont.css';
@import 'https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400;500;600&display=swap';
#button {
font-family: 'Fira Code', 'NerdFontsSymbols Nerd Font', serif;
}
dialog::backdrop {
transition: backdrop-filter 0.2s;
backdrop-filter: blur(10px) opacity(1);
cursor: pointer;
}
#results {
min-height: 20rem;
max-height: 30rem;
}
</style>
<script is:inline>
const dialog = document.getElementById('modal')
document.getElementById('button').addEventListener('click', () => {
dialog.showModal()
})
dialog.addEventListener('click', (event) => {
if (event.target === dialog) {
dialog.close()
}
})
document.querySelector('#search')?.addEventListener('input', async (e) => {
// only load the pagefind script once
if (e.target.dataset.loaded !== 'true') {
e.target.dataset.loaded = 'true'
// load the pagefind script
window.pagefind = await import('/pagefind/pagefind.js')
}
// search the index using the input value
const search = await window.pagefind.search(e.target.value)
// clear the old results
document.querySelector('#results').innerHTML = ''
// add the new results
for (const result of search.results) {
const data = await result.data()
document.querySelector('#results').innerHTML += `
<a
href="${data.url}"
class="hover:bg-primary-focus/40 block transition-colors rounded-lg px-4 py-2 hover:text-primary-content "
>
<h3 class='text-primary text-2xl'>${data.meta.title}</h3>
<p class='text-inherit hover:text-primary'>${data.excerpt}</p>
</a>`
}
})
</script>

I know it’s a lot of code, but it works wonders. The script has to be inline to run after the dialog is mounted and the pagefind can be imported after the whole page is loaded.

Excluding elements from the index

Pagefind will find all the text inside the body element by default, more info . We want to ignore the HeaderandFootercomponents. We can do this by adding a data-pagefind="ignore" attribute to the elements we want to ignore and preventing the index form being bloated with duplicated content.

src/components/Header.astro
<header data-pagefind="ignore">
<!-- ... -->
</header>

Conclusion

You can now expose a good search experience to your users, without a third-party provider. It took some work to get it working, but it’s worth it. I hope you enjoyed this post and learned something new.

If you want to deploy the site to firebase, I recommend reading this post to modify the build script .

This blog post was last updated on: 8/25/2023.