Visit homepage

Add search to your Next.js static site with Pagefind

  • Planted:

Pagefind is a neat OSS client-side search library that just recently reached v1.0.0 in September ’23. From the docs:

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.

The docs do a terrific job of explaining things, and the search experience feels really snappy (hit ⌘+K to give it a try). To get Pagefind working with Next.js, it takes a few extra steps.

Run pagefind after build

Start by installing Pagefind as a dev dependency:


npm i -D pagefind

The Pagefind CLI generates a static search index from your site’s static files after building. The --site flag indicates the output dir of your built static files, and the --output-path option is where the search bundle will be written—more on that below. I’m on Next 13.4.9 and the /pages router, so adjust as needed.

package.json

{
"scripts": {
"build": "next build",
"postbuild": "pagefind --site .next --output-path .next/static/chunks/pages/pagefind"
}
}

I’m using pnpm, which doesn’t support pre- and post- scripts by default, so I just appended && <pagefind-script> to my build script. You can also enable pre- and post- scripts with pnpm.

Depending on how you build and deploy, you might be able to avoid adding the dependency and postbuild script altogether by running npx pagefind instead.

Dynamically import search bundle

The tricky bits that tripped me up were (1) handling errors in development, and (2) configuring Webpack to allow the import to resolve correctly.

The first one is an issue because the Pagefind search index is generated after build, so it doesn’t exist when running next dev. One way to solve this is creating a /pagefind/pagefind.js file that returns some mock search results in development, or you can add a try-catch and @ts-expect-error comment to avoid creating the extra file (thanks to noxify for the tip!).

The second problem is that Webpack will bundle /pagefind/pagefind.js and output the contents into a new file, so the import path will hit a dead end. That’s where the webpackIgnore comment comes in handy, preserving the initial path. Files in the /pages directory (in v13.4.9, at least) will end up in the .next/static/chunks/pages folder upon build, hence the custom --output-path discussed above.

pages/search.tsx

export default function SearchPage() {
const [query, setQuery] = React.useState("");
const [results, setResults] = React.useState([]);
React.useEffect(() => {
async function loadPagefind() {
if (typeof window.pagefind === "undefined") {
try {
window.pagefind = await import(
// @ts-expect-error pagefind.js generated after build
/* webpackIgnore: true */ "./pagefind/pagefind.js"
);
} catch (e) {
window.pagefind = { search: () => ({ results: [] }) };
}
}
}
loadPagefind();
}, []);
async function handleSearch() {
if (window.pagefind) {
const search = await window.pagefind.search(query);
setResults(search.results);
}
}
return (
<div>
<input
type="text"
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onInput={handleSearch}
/>
<div id="results">
{results.map((result, index) => (
<Result key={result.id} result={result} />
))}
</div>
</div>
);
}
function Result({ result }) {
const [data, setData] = useState(null);
React.useEffect(() => {
async function fetchData() {
const data = await result.data();
setData(data);
}
fetchData();
}, [result]);
if (!data) return null;
return (
<Link href={data.url}>
<h3>{data.meta.title}</h3>
<p>{data.excerpt}</p>
</Link>
);
}

The search implementation on this website is public, so feel free to poke around (and suggest anything I could improve upon!).

Bells ’n whistles

Pagefind exposes some handy options for refining your index and polishing search UI. I am using both the data-pagefind-body and data-pagefind-ignore attributes to exclude certain content from my search index (e.g. header, 404 page, homepage, etc.). I also swapped pagefind.debouncedSearch() in place of the standard pagefind.search() to cut down on resource usage and prevent jarring flashes of results before you finish typing a search query.

There are options to filter and sort results, too, which I may tap into down the road. Check out the docs for other neat options, like multilingual support.

Shoutouts

Thanks to Otterlord’s blog post on using Pagefind with Astro for the nudge to share something similar for Next. If you’re keen to use Pagefind with another SSG, the docs have a list of community resources. There are also some nice demos to get a feel for the search experience before jumping into your own implementation.

And big, big thanks to bglw for helping me debug this and creating an amazing library!

Backlinks