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.js
Runtime: User hits cmd+k → palette opens → types query
→ instant filter of hardcoded nav commands
→ async pagefind.search(query) → merged results rendered

There are two kinds of search happening at once:

  1. 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.
  2. 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

Terminal window
bun add astro-pagefind
astro.config.mjs
import 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:

ArticleLayout.astro
<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">&gt;</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>&uarr;</kbd><kbd>&darr;</kbd> navigate</span>
<span><kbd>&crarr;</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:

// Header
document.getElementById('cmdk-trigger')?.addEventListener('click', (e) => {
e.preventDefault();
document.dispatchEvent(new CustomEvent('cmdk:open'));
});
// Palette
document.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!