Implement search with Pagefind

Sep 10, 2024

This post is recently updated on Jan 16, 2025 to use the new implemention of Pagefind.

The site is having more and more of blog posts now. And sometimes, when I need to find a specific post, it takes time. So a search function is much needed. After some quick research, I found a library called Pagefind. It is a search engine that can be integrated with any static site generator. In this post, I will walk you through the basic way to integrate it so that you can have something like this:

Why Pagefind?

There are a few reasons why I decided to go with Pagefind:

  • Static site compatibility: Pagefind is designed specifically for static websites, making it an excellent choice for JAMstack and other static site architectures.
  • Lightweight: The library is very lightweight, adding minimal overhead to your site.
  • Easy setup: Pagefind can be implemented with minimal configuration, often requiring just a single line of JavaScript.
  • No external dependencies: It doesn’t rely on external services or APIs, which can improve reliability and reduce costs.
  • Automatic index generation: Pagefind automatically generates a search index from your static site content.
  • Fast performance: The library offers quick search results, even on large sites. You can check out their demo on MDN Web Docs, very impressive.
  • Multilingual support: Pagefind can handle content in multiple languages.

Installation

So in this post, I will show you how to integrate Pagefind with an Astro site, which uses Vite, so the process would be similar to other Vite-based sites.

First, you need to install the library itself and the plugin for Vite. You can do it by running the following command:

npm install --save-dev pagefind vite-plugin-pagefind

Configuration

Vite/Astro

In your Vite/Astro configuration file, you need to add the configuration for the plugin:

astro.config.ts
import pagefind from 'vite-plugin-pagefind'
 
export default defineConfig({
  vite: {
    ssr: {
      noExternal: [
        `${siteConfig.site}/pagefind/pagefind.js`,
        `${siteConfig.site}/pagefind/pagefind-highlight.js`,
      ],
    },
    plugins: [pagefind()],
    build: {
      rollupOptions: {
        external: [
          `${siteConfig.site}/pagefind/pagefind.js`,
          `${siteConfig.site}/pagefind/pagefind-highlight.js`,
        ],
      },
    },
  },
  // ... other configurations
})

Then you can create a pagefind.json file at the root of your project with the configuration for the plugin. I leave most of the configurations as default:

pagefind.json
{
  "site": "dist",
  "vite_plugin_pagefind": {}
}

Indexing

In order for Pagefind to work, it needs to index your site. Let’s say you have your build output in the dist folder. You can run the following command to index it:

pagefind

But we want to run automatic after every build. So we need to add a script to our package.json file:

package.json
  "scripts": {
    "prepare": "simple-git-hooks",
    "compress": "esno scripts/img-compress-staged.ts",
    "dev": "astro dev --host",
    "build": "astro build",
    "postbuild": "pagefind",
    "preview": "astro preview",
    "lint": "eslint .",
    "lint:fix": "eslint --fix",
    "release": "bumpp"
  },

Search UI

The way I implemented the search UI is a bit custom. It consists of a search button, the search command palette, and the store for state management.

Button

src/components/Search.vue
<script setup lang="ts">
import { showSearch } from '@/stores/search'
</script>
 
<template>
  <button @click="showSearch = true">
    <span class="sr-only">search</span>
    <span class="i-ri-search-line" />
  </button>
</template>

store

src/stores/search.ts
import { ref } from 'vue'
 
export const showSearch = ref(false)

Command palette

src/components/SearchCommandPalette.vue
<script setup lang="ts">
import type { Pagefind } from 'vite-plugin-pagefind/types'
import { showSearch } from '@/stores/search'
import { debounce } from 'lodash-es'
import { onMounted, ref, watch } from 'vue'
 
const _props = withDefaults(defineProps<{
  placeholder?: string
  noResults?: string
}>(), {
  placeholder: 'Search...',
  noResults: 'No results found',
})
 
declare const window: Window & typeof globalThis
 
interface SearchResult {
  title: string
  content: string
  href: string
}
 
const input = ref<HTMLInputElement>()
const value = ref('')
const isLoading = ref(false)
const showResults = ref(true)
const currentSelection = ref(0)
const results = ref<SearchResult[]>([])
let pagefind: Pagefind
 
async function setupSearch() {
  try {
    // @ts-expect-error - dynamic import
    pagefind = await import('/pagefind/pagefind.js')
  } catch (error) {
    console.error('Pagefind module not found, will retry after build:', error)
  }
}
 
async function search() {
  if (!value.value.trim())
    return
 
  showResults.value = true
  results.value = []
  isLoading.value = true
 
  try {
    const searchResults = await pagefind.debouncedSearch(value.value)
    if (!searchResults)
      return
 
    const processedResults = await Promise.all(
      searchResults.results
        .slice(0, 5)
        .map(async (result) => {
          const data = await result.data()
          const excerpt = data.excerpt
            .replace(/<mark>/g, '<span class="bg-blue-500/20 rounded-md p-0.5">')
            .replace(/<\/mark>/g, '</span>')
 
          return {
            title: data.meta.title,
            content: excerpt,
            href: data.url,
          }
        }),
    )
 
    results.value = processedResults
  } catch (error) {
    console.error('Search failed:', error)
  } finally {
    isLoading.value = false
  }
}
 
const debouncedSearch = debounce(search, 300) // 300ms delay
 
watch(() => value.value, (newValue) => {
  if (!newValue.trim()) {
    results.value = []
    showResults.value = false
  } else {
    debouncedSearch()
  }
})
 
watch(showSearch, (newValue) => {
  if (newValue) {
    currentSelection.value = 0
    setTimeout(() => {
      input.value?.focus()
    }, 200)
  } else {
    value.value = ''
  }
})
 
onMounted(() => {
  setupSearch()
 
  window.addEventListener('keydown', (event) => {
    if (event.key === 'k' && event.metaKey) {
      showSearch.value = !showSearch.value
      event.preventDefault()
    }
 
    if (event.key === 'Escape' && showSearch.value) {
      showSearch.value = false
    }
  })
})
</script>
 
<template>
  <Transition name="fade">
    <div v-if="showSearch" class="relative z-50">
      <div
        role="dialog"
        aria-modal="true"
        class="fixed inset-0 z-10 w-screen overflow-y-auto p-4 pt-20 md:p-20"
      >
        <button
          class="fixed inset-0 z-0 cursor-default bg-stone-500/30 backdrop-blur-sm transition-opacity dark:bg-stone-950/70"
          @click="showSearch = false"
        >
          <span class="sr-only">hide search</span>
        </button>
 
        <div class="mx-auto max-w-xl transform overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all divide-y divide-stone-100 dark:bg-stone-900 dark:ring-white/20 dark:divide-white/10">
          <div class="relative flex flex-grow items-stretch focus-within:z-10">
            <input
              ref="input"
              v-model="value"
              type="text"
              :placeholder="placeholder"
              class="h-12 w-full border-0 bg-transparent pl-4 pr-4 text-stone-900 sm:text-sm dark:text-stone-100 placeholder:text-stone-400 focus:outline-none focus:ring-0"
              role="combobox"
              aria-expanded="false"
              aria-controls="options"
              @keydown="(event) => {
                if (event.key === 'Enter') {
                  if (!value.trim()) {
                    showSearch = false
                    return
                  }
                  if (currentSelection >= 0 && currentSelection < results.length) {
                    window.location.href = results[currentSelection].href
                  }
                  else {
                    showSearch = false
                  }
                }
                if (event.key === 'ArrowDown') {
                  currentSelection++
                  if (currentSelection >= results.length) currentSelection = 0
                }
                if (event.key === 'ArrowUp') {
                  currentSelection--
                  if (currentSelection < 0) currentSelection = results.length - 1
                }
              }"
            >
            <button
              type="button"
              class="relative inline-flex items-center gap-x-1.5 rounded-r-md px-3 py-2 text-sm text-stone-900 font-semibold -ml-px dark:bg-stone-900 hover:bg-stone-50 dark:hover:bg-stone-800"
              @click="search"
            >
              <slot name="icon">
                <span class="i-ri-search-line text-stone-400" />
              </slot>
              <slot />
            </button>
          </div>
 
          <Transition name="slide">
            <div v-if="isLoading" class="flex items-center justify-center p-4 text-sm text-stone-500 dark:text-stone-400">
              <span class="i-ri-loader-4-line mr-2 animate-spin" />
              Searching...
            </div>
            <div v-else-if="showResults && value.trim()">
              <div v-if="results.length > 0" class="flex flex-col scroll-py-2 overflow-y-auto text-sm text-stone-800 divide-y divide-stone-100 dark:text-stone-200 dark:divide-white/5">
                <a
                  v-for="(result, i) in results"
                  :key="i"
                  :href="result.href"
                  class="w-full select-none px-4 py-2 text-left" :class="[
                    currentSelection === i ? 'bg-white/5' : 'hover:bg-white/5',
                  ]"
                  @click="showSearch = false"
                >
                  <div class="font-semibold">{{ result.title }}</div>
                  <div class="line-clamp-2 mt-2 text-xs" v-html="result.content" />
                </a>
              </div>
              <p v-else class="p-4 text-sm text-stone-500 dark:text-stone-400">
                {{ noResults }}
              </p>
            </div>
          </Transition>
        </div>
      </div>
    </div>
  </Transition>
</template>
 
<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.1s ease;
}
 
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
 
.slide-enter-active,
.slide-leave-active {
  transition: all 0.1s ease;
}
 
.slide-enter-from,
.slide-leave-to {
  transform: translateY(-10px);
  opacity: 0;
}
</style>

Usage

Now you can use the search button and the command palette site. Let’s say we add it to the header component:

src/components/Header.astro
---
import Search from './Search.vue'
import CommandPalette from './SearchCommandPalette.vue'
// ...
---
 
<header class="header relative z-40">
  <!-- <div>...</div> -->
  <Search client:visible />
  <!-- <div>...</div> -->
</header>
<CommandPalette client:load />
<!-- <div>...</div> -->

Conclusion

I hope this post has been helpful in setting up a search functionality for your website. If you have any questions or suggestions, feel free to reach out. I’m always happy to help!