Building a cmd+k Search Palette with Pagefind in Astro
How I added a command palette with full-text search to my Astro site using pagefind and some vanilla JS
While migrating this site from Hugo to Astro, I wanted to add a cmd+k command palette with full-text search. My old Hugo site didn’t have anything like this, so it felt like a good excuse to build one during the rewrite.
I ended up using pagefind for search (it builds a static index at build time, no server needed) and just wrote the palette UI myself in vanilla JS. The whole thing is about 300 lines. Here’s how it works.
#How it fits together
Build time: astro-pagefind indexes your HTML → /pagefind/*.pf_index, pagefind.jsRuntime: User hits cmd+k → palette opens → types query → instant filter of hardcoded nav commands → async pagefind.search(query) → merged results renderedThere are two kinds of search happening at once:
- Command filtering — a hardcoded list of nav items and actions (Home, Blog, Toggle theme, etc.) gets filtered on every keystroke. This is just array filtering, so it’s instant.
- Full-text search — the query goes to pagefind after a 150ms debounce. Results get appended below the commands.
The trick is showing the commands right away so it never feels like you’re waiting for anything.
#Setting up pagefind
bun add astro-pagefindimport pagefind from 'astro-pagefind';
export default defineConfig({ integrations: [ // ... your other integrations pagefind(), ],});That’s the whole setup. At build time it indexes your HTML and writes the search index + client JS to dist/pagefind/. It also works during astro dev.
#Marking what to index
Pagefind uses data attributes. Put data-pagefind-body on the stuff you want indexed, data-pagefind-ignore on what you don’t:
<article data-pagefind-body data-pagefind-meta={`type:${contentType}`}> <h1>{title}</h1> <slot /></article>
<aside class="toc-sidebar" data-pagefind-ignore> <!-- table of contents, not searchable --></aside>data-pagefind-meta lets you attach metadata to each page. I use it to tag content types (article, project) so the palette can group search results into sections.
#The palette component
It’s a single .astro file — HTML, inline script, and CSS. Nothing wild.
#HTML
<div class="cmdk-overlay" id="cmdk-overlay"> <div class="cmdk" role="dialog" aria-label="Command palette"> <div class="cmdk__search"> <span class="cmdk__icon" aria-hidden="true">></span> <input type="text" class="cmdk__input" id="cmdk-input" placeholder="Search or type a command..." autocomplete="off" spellcheck="false" /> <kbd class="cmdk__esc">esc</kbd> </div> <div class="cmdk__list" id="cmdk-list" role="listbox"></div> <div class="cmdk__footer"> <span><kbd>↑</kbd><kbd>↓</kbd> navigate</span> <span><kbd>↵</kbd> select</span> </div> </div></div>Overlay, dialog, input, list, footer. That’s it.
#The command list
var commands = [ { label: "Home", section: "Navigate", icon: "home", action: function () { go("/"); }, keywords: "index main" }, { label: "Blog", section: "Navigate", icon: "blog", action: function () { go("/blog"); }, keywords: "articles posts" }, { label: "Toggle theme", section: "Actions", icon: "theme", action: function () { toggleTheme(); }, keywords: "dark light mode" }, // ...];The keywords field is for matching synonyms — type “dark” and it’ll match “Toggle theme”. Filtering is just checking if every word in the query appears somewhere in the label + keywords:
function filterCommands(q) { if (!q) return commands.slice(); return commands.filter(function (c) { var haystack = (c.label + " " + c.section + " " + (c.keywords || "")).toLowerCase(); var words = q.split(/\s+/); for (var i = 0; i < words.length; i++) { if (haystack.indexOf(words[i]) === -1) return false; } return true; });}#Loading pagefind
Pagefind is ~33KB of JS + a WASM module, so I don’t load it until the palette actually opens:
var pagefind = null;
function loadPagefind() { if (pagefind) return Promise.resolve(pagefind); return import("/pagefind/pagefind.js").then(function (pf) { pagefind = pf; return pf.init().then(function () { return pagefind; }); }).catch(function () { pagefind = null; return null; });}I call loadPagefind() as soon as the palette opens (before the user starts typing) so the WASM is hopefully ready by the time they actually search.
#The search handler
This is where the two layers come together:
function doSearch(query) { var q = query.trim(); var filtered = filterCommands(q.toLowerCase());
// Show command matches right away allItems = filtered; activeIndex = 0; renderItems();
if (!q) return;
// Then do the pagefind search loadPagefind().then(function (pf) { if (!pf) return; pf.search(q).then(function (search) { return Promise.all( search.results.slice(0, 12).map(function (r) { return r.data(); }) ); }).then(function (results) { // Group by content type var grouped = {}; for (var i = 0; i < results.length; i++) { var r = results[i]; var type = (r.meta && r.meta.type) || "article"; if (!grouped[type]) grouped[type] = []; grouped[type].push({ label: r.meta.title || "Untitled", section: sectionLabels[type], subtitle: r.excerpt ? r.excerpt.replace(/<\/?mark>/g, "").slice(0, 80) : undefined, action: function (url) { return function () { go(url); }; }(r.url), }); }
// Merge commands + search results allItems = filtered.concat(searchItems); renderItems(); }); });}Render what you have immediately, update when pagefind comes back. No spinner needed.
#Pagefind’s API
Quick overview if you haven’t used it:
var search = await pagefind.search("my query");// search.results = [{ id, score, data() }, ...]
var data = await search.results[0].data();// data = {// url: "/blog/some-post",// meta: { title: "Some Post", type: "article" },// excerpt: "...matching text with <mark>highlights</mark>..."// }search() gives you lightweight handles. .data() fetches the actual content (title, URL, excerpt). It’s lazy — you only load fragments for results you display. The meta object has whatever you put in data-pagefind-meta.
#Debouncing
Commands filter on every keystroke. Pagefind gets a 150ms debounce:
input.addEventListener("input", function () { if (searchDebounce) clearTimeout(searchDebounce); allItems = filterCommands(input.value.toLowerCase().trim()); renderItems(); if (input.value.trim()) { searchDebounce = setTimeout(function () { doSearch(input.value); }, 150); }});There’s also a staleness check — when results come back, verify the input hasn’t changed:
if (input.value.trim() === q) { allItems = filtered.concat(searchItems); renderItems();}#Keyboard nav
Arrow keys, Enter, Escape. The usual:
input.addEventListener("keydown", function (e) { if (e.key === "ArrowDown") { e.preventDefault(); activeIndex = Math.min(activeIndex + 1, allItems.length - 1); renderItems(); scrollActive(); } else if (e.key === "ArrowUp") { e.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); renderItems(); scrollActive(); } else if (e.key === "Enter" && allItems[activeIndex]) { e.preventDefault(); allItems[activeIndex].action(); }});#Opening the palette
A button in the header fires a custom event, and the palette listens for it:
// Headerdocument.getElementById('cmdk-trigger')?.addEventListener('click', (e) => { e.preventDefault(); document.dispatchEvent(new CustomEvent('cmdk:open'));});
// Palettedocument.addEventListener("keydown", function (e) { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); isOpen() ? closePalette() : openPalette(); }});
document.addEventListener("cmdk:open", function () { if (!isOpen()) openPalette();});Custom event keeps things decoupled. Easy to add more triggers later.
#Why not use the cmdk package?
cmdk is a React component. This site doesn’t use React. Shipping React for a search box would be silly.
Beyond that, pagefind’s API is literally two functions. There’s not enough complexity here to justify a library. The whole palette is ~300 lines of vanilla JS in a <script is:inline> block — less code than cmdk’s bundle.
#How it performs
- Pagefind doesn’t load until you open the palette
- First search takes ~200ms (WASM init). After that it’s basically instant
- Index for ~57 pages is about 50KB, loaded in chunks
- Everything is static files, cached by the browser
If you want to try it, hit cmd+k on this page.
This work is licensed under CC BY-NC-SA 4.0. Copying is an act of love — please copy!