Pagefind in practice: full-text search without a server, database or subscription
Pagefind from setup to indexing: filters, multilingual sites, accessibility, performance, costs — with measurements from two projects and clear limits.
by Jean Pierre Kolb ·
Press Ctrl+K on this page (⌘+K on a Mac) or tap the magnifying glass. A search opens over close to a thousand pages in two languages, and the results are there before you have finished typing. Nothing runs on my server for this: no PHP, no database, no search service, no subscription. What you are searching is a handful of files created during the build. The tool behind it is called Pagefind (opens in a new tab), and I now use it in two projects: in this knowledge base and in the JPKCom Desktop, whose search at jpkc.com/desktop/ shares the very same index.
I am very happy with Pagefind, and this article makes no secret of it. But it is meant to be more than a love letter. I will show you how the setup works, what really matters when indexing, how filters, tags and multiple languages fit together, what this means for accessibility, how much data a search actually costs — measured, not estimated — and where Pagefind hits limits you should know before you decide.
The short version
| Question | Answer |
|---|---|
| What is Pagefind? | A static full-text search: a program indexes the finished HTML after the build, and the browser searches the index with WebAssembly |
| What runs on the server? | Nothing. The index consists of static files that any web server can deliver |
| What does it cost? | Nothing. Open source under the MIT license, no service, no account |
| What does a search load? | Measured here: about 100 KB when the search is first opened, then 70–125 KB per search term (more when typing slowly), on close to 1,000 pages |
| How long does indexing take? | 6.6 seconds here for 996 pages, in a full build of just over four minutes |
| Multilingual? | Yes, automatically: one index per language, picked via <html lang>, word stemming for English, German and many other languages |
| The main limits | No typo tolerance, no splitting of compound words, no real-time index, everything in the index is public |
What Pagefind is
Pagefind is a search library for static websites. Liam Bigelow (opens in a new tab) introduced it at CloudCannon in July 2022; today the project describes itself as an independent open source project originally created at CloudCannon, and the repository lives at github.com/Pagefind/pagefind (opens in a new tab). Pagefind is written in Rust (opens in a new tab) and licensed under MIT. The current version is 1.5.2 from 12 April 2026, which is also the one I use here.
The principle is quickly explained, and it is also the reason I like Pagefind so much:
- After the build, the Pagefind program runs over the output folder of your static site generator — here Eleventy's
_site/. It reads the finished HTML, not your Markdown sources. So it does not matter what built the site: Eleventy, Hugo, Astro, Jekyll or a folder of hand-written HTML files. - Pagefind writes an index as a
pagefind/folder right into the built site. The index is split into many small files, alphabetically by word beginnings, plus one fragment per page with title, URL and text. - In the browser, a small JavaScript file loads a WebAssembly (opens in a new tab) module that runs the actual search. It only fetches the index chunks that match the search term, and the fragments of the results you display.
The project site puts the goal like this: a full-text search on a 10,000-page site should need a total network payload under 300 KB, including the library itself, and closer to 100 KB for most sites. Further down I will show you what that looks like here.
Setup: one command after the build
For most projects, one line is enough. In this knowledge base, the package.json contains (shortened):
"build": "npm-run-all --serial \"build:*\" pagefind",
"pagefind": "rimraf ./_site/pagefind && pagefind --site ./_site"Two details are hidden in there. Pagefind sits directly in the build script rather than in a postbuild hook, because my project sets ignore-scripts=true in its .npmrc — the reasons are in Working securely with Node.js and npm. That also switches off hooks like postbuild, so the index is built as an explicit step. And before every run I clear out the old index: the file names carry hashes of their content, and without clearing, chunks from earlier builds would stay behind and be deployed along with the new ones. The package itself needs no postinstall, by the way: since version 1.0 the binary for your operating system comes through platform-specific optional dependencies.
For the interface you have three options:
- The ready-made UI. Since version 1.5, Pagefind recommends the new Component UI: web components such as
<pagefind-modal>,<pagefind-searchbox>or<pagefind-results>that you simply drop into your HTML. The older Default UI and the Modular UI are still available and still supported. New features land in the Component UI first, though, and the docs explicitly call the Default UI "no longer the primary recommendation". - The JavaScript API. You import
pagefind.js, callsearch()and build the interface yourself. That is my approach in both projects. - The indexing API for Node.js and Python, if you want to index your own records instead of HTML files. More on that further down.
The API is pleasantly small. At its core, my search looks like this:
const pagefind = await import("/db/pagefind/pagefind.js");
await pagefind.init();
const search = await pagefind.search("docker");
const results = await Promise.all(
search.results.slice(0, 10).map((result) => result.data())
);
// results[0].url, results[0].meta.title, results[0].excerptsearch() first returns only result IDs with their scores. Title, URL and excerpt are loaded by data(), separately for each result. That is the trick Pagefind uses to save bandwidth: what you do not display is not loaded.
A subfolder instead of the domain root. This site lives under /db/, not at the root of the domain. Eleventy's HTML transform rewrites internal links to /db/… automatically, but not a JavaScript import. That is why the path is hard-coded as /db/pagefind/pagefind.js. Pagefind works out where the index files are from the address pagefind.js was loaded from; if needed, you set it with the basePath option.
Indexing: what belongs in the index — and what does not
This is where your search succeeds or fails. Pagefind makes it easy, but not foolproof.
The default. Without further instructions, Pagefind indexes the complete <body> of every page. On its own it leaves out, among others, <nav>, <footer>, <script>, <form>, <label>, <svg>, <iframe> and <template>. That sounds like enough, but it is not: <header>, <aside> and every <div> end up in the index. A number from this knowledge base shows how much that matters. The "Copy page" menu is a <div> with the entries "Open in Claude" and "Open in ChatGPT", and it sits on every article page. Without excluding it, a search for "Claude" would hit practically every one of the close to 500 pages in its language instead of the close to 50 that actually mention Claude — of all things, for one of the terms this knowledge base revolves around most.
Two ways to handle this. With data-pagefind-body on the article container you say: only this counts. With data-pagefind-ignore on individual elements you say: not this. The first way is stricter but has a sharp edge: as soon as a single page carries data-pagefind-body, Pagefind indexes only pages with this attribute. If you create a new layout a year from now and forget the attribute, its pages silently disappear from the search. That is why I chose the second way: every element that looks the same on many pages — header bar, sidebars, table of contents, pager, the copy menu — carries data-pagefind-ignore. In the worst case, too much slips into the index, and I can see that. A missing result, on the other hand, goes unnoticed.
Generated content can get in the way, too. The lists on my overview pages repeat titles and descriptions of articles that are already in the index on their own. As a result, the overview pages matched almost every query from their section and pushed aside the article you were actually looking for. Since these lists are ignored as well, the indexed text of the ten section overviews dropped from 7,186 to 671 words. My rule of thumb from this: if a text already appears on another indexed page, it does not belong in the index here.
Excluding whole pages. The 404 page with its tag cloud would match almost any topic search. In my layout, a front matter switch puts data-pagefind-ignore on its <body>. Pages without any <html> element are not processed by Pagefind by default — handy for the HTML snippets of my htmx demo, which stay out automatically.
Weighting. Headings count more than body text: h1 at 7.0, h2 at 6.0 and so on down to 1.0 for regular text. With data-pagefind-weight you set your own values between 0 and 10, and they apply quadratically — a 2.0 counts about four times as much. Since version 1.5, Pagefind also searches the metadata and gives a match in the title five times the weight by default.
Metadata. Pagefind takes the title from the first h1 and the image from the first <img> after it. With data-pagefind-meta you override both or add your own fields, such as a date or an author, which your interface can then display. And with data-pagefind-index-attrs="title,alt" attribute texts end up in the index too, for example the alt texts of your images.
Sub results. Since version 1.0, Pagefind splits a page into sections at headings with an id and returns them as sub_results with their own anchor. If you have long articles, you can take readers straight to the matching section instead of just the page.
Check instead of hope. The index is not a black box. The fragment files are gzip-compressed and contain — after a short pagefind_dcd marker — JSON with the complete indexed text of each page. After every change to the layouts, I unpack them with a script and count on how many pages suspicious terms such as "Claude" or "On this page" appear. If a count gets close to the total number of pages, interface text has slipped into the index somewhere. Since version 1.4 there is also the Pagefind Playground, which shows how a result was scored. It is always available with --serve; it only goes onto the live site if you explicitly ask for it.
Filters, categories and tags
Pagefind can do more than full text. With data-pagefind-filter you attach filter values to a page that the search can then narrow down by — categories, tags, language, author, whatever you need. On top of that, data-pagefind-sort gives you custom sort orders, for example by date.
In this knowledge base, each article page carries up to four kinds of filters: section (blog, cheat sheets, tools, …), language, category and each of its tags. The markup is unspectacular, with a lesson inside:
<span hidden data-pagefind-filter="bereich:blog"></span>
<span hidden data-pagefind-filter="sprache:de"></span>
<span hidden data-pagefind-filter="tag:accessibility"></span>
<span hidden data-pagefind-filter="tag:performance"></span>(The filter keys are German — bereich is section, sprache is language — because German is this site's default language.) Each value sits in its own element. A fixed value in the form key:value may appear only once in an attribute and only at the end of a list: Pagefind takes everything after the first colon as the value, commas included. So bereich:blog, sprache:de becomes a single filter bereich with the value "blog, sprache:de", and sprache is missing entirely — without any error message. The quickest check is the build output: here it reports "Indexed 8 filters", meaning four filters per language, added up across both languages. I look at this line after every change to the layouts.
Tags without tag pages. The topic navigation of this site is built on these filters. If you click a tag chip at the top of an article, the search opens with the tag filter and the language of the current page — without a search term, so with all pages on that topic:
const search = await pagefind.search(null, {
filters: { tag: ["accessibility"], sprache: ["en"] },
});If you then type something, the search drops the tag filter and searches everywhere again. So I do not need dedicated tag overview pages; with hundreds of tags, that would be hundreds of thin pages nobody maintains. By the way, filters() also gives you the number of results per filter value, in case you want to build a faceted search with counts.
Multilingual sites: one index per language, no configuration
This is one of the places where Pagefind works quietly and well. When indexing, it reads the lang attribute of the <html> element and builds a separate index for each language. In the browser it looks at the same attribute and loads only the matching index. On an English page you search the 498 English pages, on a German one the 498 German pages, without any setting. During the build, Pagefind simply reports "Discovered 2 languages" and lists the pages per language.
For English and German, Pagefind comes with stemming (opens in a new tab): a search for "search engine" also finds "search engines", and on the German side "Kontaktformulare" (contact forms) finds "Kontaktformular" (contact form). The language table in the docs (opens in a new tab) lists stemming for just over 30 languages, from Arabic to Turkish, plus interface translations for more than 40. For languages without stemming, search still works, just without matching across word forms. Chinese, Japanese and Thai, which do not separate words with spaces, are segmented into words at indexing time by the extended Pagefind build, and since 1.5 the search query in the browser is segmented too.
Two things you should know. First: searching across languages only works with a trick — either one shared index via the force_language option, but then without matching stemming for the second language, or an additionally merged index (more on that shortly). Second: if your site switches language without a reload, you have to restart Pagefind with destroy() and init() so it picks the right index. The JPKCom Desktop does exactly that when you switch its language.
Since version 1.5, search also matches across accents: "cafe" finds "café", although exact matches rank higher.
Accessibility: Pagefind delivers data, the interface decides
With accessibility it pays to look closely at what Pagefind does and what it does not do. The search engine itself delivers data. Whether a screen reader learns that there are twelve results, whether you can reach the results with the arrow keys and where focus goes when the dialog closes is decided entirely by the interface.
The old Default UI had gaps here. An audit against the French RGAA standard documented three findings in 2024 in issue #727 (opens in a new tab): no visible label on the search field, an empty list in the markup, and a result count that is not announced to screen readers. The issue was not closed with a fix to the old interface but with a pointer to the new Component UI.
The Component UI is built much better. According to the docs, it follows the patterns of the WAI-ARIA Authoring Practices, and the source confirms it: the <pagefind-modal> search window is a native <dialog> that makes the rest of the page inert, closes with Escape and returns focus to the trigger on close. Inside is a labelled search field, and the arrow keys move focus through the result links. The dropdown variant <pagefind-searchbox>, on the other hand, implements the combobox pattern: a search field with aria-activedescendant over a listbox, operated with the arrow keys, Enter and Escape. A visually hidden live region announces result counts politely, while "no results" and errors are announced assertively. Assistive texts are translated into the language of the page. I have not tested the Component UI with a screen reader — if you use it, check it in your own context.
What this means in practice. Under WCAG 2.2, the result count is a status message (opens in a new tab) in the sense of success criterion 4.1.3; the list itself is not. The German BITV-Test explicitly gives "5 search results" as an example in test step 9.4.1.3 (opens in a new tab) (German) and recommends a container that is already present and empty in the DOM when the page loads and is marked up as a live region, for example with role="status" and aria-atomic="true". Add to that the basics: keyboard operability (2.1.1), a meaningful focus order (2.4.3) — focus into the search window and back to the trigger on close — and a search field with a real label (1.3.1).
My own interfaces. Because I work with the API in both projects, this is on me. The search in the JPKCom Desktop is a combobox with a listbox, grouped by apps, collections and articles, and it announces the result count as soon as you pause typing. The palette of this knowledge base uses a native <dialog> with a visually hidden label and a live region for the result count. That is the real advantage of the API: you do not depend on the decisions of someone else's interface, but you also carry the responsibility.
If you want to go deeper: how search fits into the page structure as a search landmark is covered in HTML landmarks in practice.
Customising function and design
Design. The bundled interfaces can be customised, within limits: the Component UI is built from web components, shields itself from your site's CSS and is styled through its own CSS custom properties (--pf-*) and your own templates. If you build on the API, there is no design to fight at all. My palette looks like the rest of this site because it is built from the same design tokens and classes. In the Desktop, the search follows the current theme because it is simply another window element there.
Function. With pagefind.options() you set the length of the excerpts (excerptLength, 30 words by default), and you tune the ranking with the ranking option. Since version 1.1, Pagefind scores results with a BM25-style method, and the knobs are numeric: how much term frequency counts, how strongly long pages are penalised, how strongly a word that exactly matches the search term is preferred over one that merely extends it. With highlightParam, Pagefind appends the search term to the result URL, and the bundled pagefind-highlight.js script then highlights the matches on the target page.
Merging several indexes. With mergeIndex() the browser additionally searches the index of another website or another subfolder in the same query, with its own weighting and a synthetic filter value per index so you can show where a result came from. If the other index lives on a different domain, that domain needs CORS headers.
await pagefind.mergeIndex("https://docs.example.com/pagefind/", {
indexWeight: 0.5,
mergeFilter: { source: "Docs" },
});Performance: what a search really costs
These are the measurements I refer to throughout the article. I took them on 14 October 2026 on a fresh build of this site with Pagefind 1.5.2, with the search itself run in a headless Chromium against a local server, so without network latency.
The index. 996 pages, 498 per language, around 1.2 million words of text, close to 70,000 search terms across the two language indexes (about 45,000 German, 24,000 English), eight filters. Pagefind needs 6.6 seconds for this. The full build with Eleventy, syntax highlighting and all transforms takes just over four minutes; search accounts for less than three percent of it. On disk the index folder takes up 8.2 MB, spread over 108 index chunks and 996 fragments.
Opening the search. My palette only loads Pagefind when you open it for the first time. If you do not search, you do not load a single byte of it, so the search has no effect on the page's Core Web Vitals. Opening it fetches five files: pagefind.js, the web worker, a small entry file, the language metadata and the WebAssembly module. Compressed the way my Apache delivers them, that is about 100 KB, 70 KB of it WebAssembly alone.
Each search. Per search term, Pagefind loads one index chunk of around 30–40 KB and then the fragments of the results being displayed. My palette shows ten results and therefore loads ten fragments. Together that came to 70–125 KB per search term when the term arrived in one go. If you type more slowly than the palette waits, intermediate searches run as well, and a long word can then cost 200–350 KB. The fragments make up the larger share because each one contains the indexed text of its page — the excerpts are built in the browser. Locally, results appeared after around 0.3–0.6 seconds, including the 150 milliseconds my palette waits after the last keystroke.
Two practical levers follow from this. Only call data() for results you actually display: with five instead of ten, the data per search drops noticeably. And the index files — index chunks, fragments, metadata and the WebAssembly module — come gzip-compressed out of the box, so the search works without any server setting. pagefind.js and the worker, however, ship uncompressed, together about 86 KB. Only the usual gzip or Brotli compression of the server brings them down to about 25 KB, and only that way do you get the 100 KB on first open. Since version 1.5, search also runs in a web worker, keeping the main thread free, and a new encoding has made the index chunks about 45% smaller.
Stability and security: what does not run cannot overload
I have worked with WordPress for many years, and search is the place I watch most closely when it comes to load. WordPress's built-in search is decent out of the box, but no more: it splits the search term into words and checks each one with an SQL LIKE '%word%' against the title, excerpt and content of all published content: posts, pages and public custom post types. No full-text index is involved, and because the pattern starts with a wildcard, no regular database index helps either — the database has to scan the content. Since WordPress 3.7, title matches come first, but word stemming, typo tolerance or suggestions while typing are not available without a plugin.
The real problem is load. Search requests are a bad case for page caches: according to its docs, WP Rocket does not cache search results by default, LiteSpeed Cache excludes them in its code, W3 Total Cache skips URLs with parameters out of the box, and WP Super Cache does not create a static copy for them but at most a cache file served through PHP — which only helps when exactly the same term is searched again. So every new search term starts PHP and queries the database. That applies to every visitor, but also to every bot, and bots like to ask in bulk. I know the pattern from practice: many requests to /?s= in a short time spawn a lot of PHP child processes and put a heavy load on the database server that slows down every other visitor too — whether the requests come from well-behaved crawlers or from malicious bots. Google now describes the problem itself: in episode 113 of the "Search Off the Record" podcast (July 2026), John Mueller explains that internal search pages are usually not cached, that every request triggers a database lookup plus ranking, and that spammers deliberately link to millions of search URLs with spam terms so that these pages rank on other people's domains.
With Pagefind, that is history for me. A search only reaches the server as a request for static files, which cache well and cost hardly any computing time. There is no endpoint that accepts search terms — no expensive request per search term that can be multiplied, no database, no SQL injection, no search URLs for spammers to link to. That makes Pagefind not just a convenience for me but also a stability and security factor. I serve the index folder itself with X-Robots-Tag: noindex, nofollow so that search engines do not pick up the index files as pages. Pagefind has no official recommendation on this; it is my own decision.
A note on the Content Security Policy. Because Pagefind compiles WebAssembly, a strict CSP needs 'wasm-unsafe-eval' in script-src. That only allows compiling WebAssembly, not eval(). Since version 1.5, Pagefind compiles in the worker, and the worker runs under the CSP your server sends with pagefind-worker.js. If the worker fails or does not respond within five seconds, Pagefind falls back to the page, and then the page's policy applies. So you are only safe if neither policy blocks compiling: if one of them restricts script-src, it needs 'wasm-unsafe-eval'. For the worker itself, worker-src 'self' is enough in the Desktop; I check that there with a dedicated browser test under the real policy.
Sustainability and costs
What you save. Pagefind is free, and it needs nothing that costs money: no search service, no search server of your own, no bigger hosting plan for more PHP processes. For comparison, the prices of hosted services as of October 2026:
| Solution | Pricing model |
|---|---|
| Pagefind | free, static files on the web space you already have |
| Algolia | Free plan with 10,000 search requests per month; on the Grow plan, USD 0.50 per additional 1,000 requests and USD 0.40 per 1,000 records above 100,000 |
| Algolia DocSearch | free, but only for public technical documentation and technical blogs, after review |
| Meilisearch Cloud | from USD 20 per month |
| Typesense Cloud | hourly price per cluster depending on size, even without traffic, plus bandwidth |
| Elastic Cloud Hosted | from USD 99 per month |
For a small website, Algolia's free plan is often enough, and I do not want to play that down. But each of these solutions is one more service with an account, keys, a privacy review and a price list that can change. With Pagefind, your visitors' search terms stay in their browser — there is no third party that sees them and nothing you have to explain in your privacy policy.
What the environment saves. To be honest, the savings cannot be expressed cleanly in grams of CO₂. The common Sustainable Web Design Model (opens in a new tab), version 4, works from the amount of data transferred and explicitly does not estimate the computing effort of individual servers. The direction is clear all the same: a search that only fetches a few static, cached files replaces a PHP process and a database query per search term — and above all the load from bots that benefits nobody. The W3C's Web Sustainability Guidelines recommend the same principle under the heading "Static over dynamic" and elsewhere advise limiting unwanted bots. What fewer moving parts achieve overall is covered in Sustainable websites.
More use cases
One index, two interfaces. This is my favourite example because it works with so little fuss. The JPKCom Desktop at jpkc.com/desktop/ has its own search across apps, collections and bookmarks. As an additional provider, it plugs in the Pagefind index of this knowledge base — the same files under /db/pagefind/ that the palette here uses. There is no second index, no export and no syncing. In the Desktop's configuration, it takes a few lines:
search: {
pagefind: {
path: '/db/pagefind/pagefind.js',
excerptLength: 16,
maxHits: 8,
label: 'JPKCom DB',
},
},The Desktop loads the index only when you open the search and shows the results as their own "JPKCom DB" group below the apps. How the Desktop came about is in the making-of; the options for site operators are in the JPKCom Desktop guide. The Pagefind connection is optional there and can be switched on for any desktop.
Several websites, one search. With mergeIndex() you combine the indexes of several websites in the browser, for example main site, blog and docs on different subdomains. Each website builds its own index, and the search only joins them on the visitor's side.
Content that is not HTML. Through the Node.js API — and since version 1.2 also through Python — you add records directly: text from PDFs, subtitles, product data from a JSON file, entries from an app. Pagefind only takes care of the indexing; you extract the text from a PDF yourself beforehand.
import * as pagefind from "pagefind";
const { index } = await pagefind.createIndex();
await index.addCustomRecord({
url: "/downloads/manual.pdf",
content: "The text extracted from the PDF …",
language: "en",
meta: { title: "Manual (PDF)" },
});
await index.writeFiles({ outputPath: "./_site/pagefind" });
await pagefind.close();Documentation and internal knowledge bases. Wherever a folder of HTML files sits on a web server — project documentation, manuals, an intranet on simple web space — one command gives it a search. Astro Starlight (opens in a new tab) ships Pagefind by default, and the official Eleventy documentation (opens in a new tab) searches with Pagefind too, with DuckDuckGo as the fallback without JavaScript.
The limits — clearly stated
As happy as I am: Pagefind is not a replacement for every search. You should know these limits.
No typo tolerance. Pagefind compares word beginnings, not similarity. I tested it on the German index of this site: "barrierefrei" (accessible) and "Barriere" (barrier) find "Barrierefreiheit" (accessibility) because they are beginnings of that word. The typo "barierefreiheit", however, finds nothing relevant; Pagefind then falls back to the longest word beginning that exists in the index and returns results for "bar" — progress bars in htop and the like. If you need to catch typos, you are better off with Algolia, Meilisearch or Typesense.
No splitting of compound words. This hits German-language sites in particular, and any language that builds long compounds. "Formular" (form) finds "PDF-Formular" (PDF form) because the hyphen separates the words, but "freiheit" (the "-freedom" part) does not find "Barrierefreiheit" — the word part is not at the beginning. In English, the same applies to closed compounds: "board" will not find "keyboard". For such content this means: have important terms appear separately or hyphenated in the text at least once.
No real-time index. Pagefind indexes at build time. Whatever changes only becomes searchable after the next run. For blogs and documentation that is not a problem; for shops with stock levels or for forums it is. Content that is created in the browser with JavaScript is not in the built HTML and therefore does not end up in the index either.
Everything in the index is public. The fragment files contain the indexed text of every page, and anyone can download them. There is no permission check at search time. Internal or protected content does not belong in a publicly served index — exclude it when indexing.
JavaScript and WebAssembly are required. Without both, there is no search. The Eleventy docs show a good way out: a regular form that sends the query to an external search engine when JavaScript is not available.
No analytics. Because the search runs in the browser, you do not learn what people searched for. The Component UI emits events you can wire up to your own statistics; nothing is built in. From a privacy point of view, I see that as an advantage rather than a drawback.
Ranking only through knobs. You can weight and shift parameters, but you cannot write your own scoring function, maintain synonyms or pin specific results to the top the way hosted services allow.
Very large websites. Pagefind does not name a hard upper limit; the stated goal is sites with tens of thousands of pages. On the Hugo forum, one user reports that indexing broke down for them at around 300,000 pages — a single report without a version number, but a hint that you should test before committing if you have millions of pages.
Alternatives: when something else fits better
| Solution | When it fits better |
|---|---|
| Algolia / DocSearch | When typo tolerance, synonyms, analytics and — with Algolia itself — real-time updates matter and an external service is not a problem. DocSearch is free for public technical documentation and technical blogs but indexes with a crawler at fixed intervals |
| Meilisearch, Typesense | When you want the features of a search server — typo tolerance, facets, real time — but want to host it yourself or stay with open source |
| Elasticsearch / OpenSearch | For large data volumes, complex queries and log search; usually too heavy for a website |
| Fuse.js, MiniSearch, FlexSearch | For small data sets in the browser, such as a list of a few hundred entries. They usually keep the whole index in memory, but in return they can search fuzzily and tolerate typos |
| Lunr.js | Probably not any more: the latest version dates from 2020 |
| Stork | Not any more: its author stopped development in 2023 and named Pagefind — alongside Tinysearch — as the closest alternative |
The decisive difference from the browser libraries is the chunked index. Fuse.js, MiniSearch or Lunr usually load all the data before the first search runs. With a few hundred entries that does not matter; with a thousand full-text articles it does. Pagefind only loads what the query needs and therefore scales far beyond what a pure browser library can sensibly handle.
FAQ
Does Pagefind work with every static site generator?
Yes. Pagefind reads the finished HTML after the build and does not know your generator at all. Eleventy, Hugo, Astro, Jekyll, Zola or a hand-written HTML folder — all that matters is that HTML files come out at the end. You should still set a lang attribute on <html>, because that is how Pagefind detects the language, builds one index per language and picks the matching stemming.
How do I get Pagefind results that point to one section of a page?
Through sub results. Give your headings an id — many Markdown renderers do that automatically — and every result additionally returns sub_results with the matching section and anchor. In the Component UI, <pagefind-results> shows them on its own; for <pagefind-searchbox> you switch them on with the show-sub-results attribute.
Do I need any server configuration for Pagefind?
No. The index files come compressed and work on any web space; for pagefind.js and the worker, the usual server compression is worth having. Only with a strict Content Security Policy do you need to allow 'wasm-unsafe-eval', and if you merge indexes across domains, you need CORS headers.
Is Pagefind accessible?
The search engine delivers data; accessibility comes from the interface. According to its docs, the new Component UI follows the WAI-ARIA patterns and announces result counts; the old Default UI had documented gaps. If you build your own, make sure of a real label, keyboard operation, focus management and a live region for the result count.
Can Pagefind search PDFs?
Not directly. The command-line program only reads HTML. Through the Node.js or Python API, however, you can create your own records and feed in text from PDFs that you have extracted yourself beforehand.
Conclusion
For me, Pagefind is one of those rare tools that do exactly what they promise and create less work rather than more. One command in the build, a folder of static files, and a website with a thousand pages in two languages has a fast, filterable full-text search — without a server, without a database, without a subscription and without an expensive request per search term that bots could use to bring the server to its knees. That the same index also feeds the search of a completely different project shows how well the principle can be reused.
The limits are real: no typo tolerance, no compound splitting, no real-time index, everything public. If your website cannot live with one of them, use a search server. For blogs, documentation and knowledge bases, Pagefind is my first choice — and indexing is the part where you should put most of your care.
Further reading
Why this site is built statically in the first place is explained in Why 11ty. What fewer moving parts mean for power and costs is in Sustainable websites. How the Desktop came about, whose search shares the index here, is shown in the JPKCom Desktop making-of. And where search belongs in the page structure is described in HTML landmarks in practice.
The primary sources: the Pagefind documentation (opens in a new tab), the changelog (opens in a new tab) and the repository on GitHub (opens in a new tab).