Tracking Pagefind Search with Umami

Today I feel very productive. I set up a new Umami instance on Railway (that’s my referral link) to add some simple analytics to this site. Setting it up on Railway was super easy, just a few clicks and it was live. The only tricky part was setting up my own domain for it.

My Umami Dashboard

I also have Pagefind set up for site search. It’s a fully static search library that runs in the user’s browser, which means I don’t have to host any backend for it. Since my site is built with Astro, I use the astro-pagefind package to make integration simple.

Pagefind Integration

Then I got an idea: could I track what people search for? It would be interesting to see what topics visitors are trying to find. I checked the Pagefind docs, but it turns out there isn’t a built-in event listener for searches. You can’t just write some code to listen for a search-completed event, because it doesn’t exist.

Watching for DOM Changes

After some digging, I found a GitHub issue where Tom Vincent posted a clever workaround. The idea is to use a MutationObserver.

This is a browser feature that lets you watch for changes to the HTML of a page. You can tell it to watch a specific element, and it will notify you whenever child elements are added, removed, or changed. When Pagefind shows search results, it adds new elements to the page, which is exactly the kind of change we can watch for.

Here is the basic concept:

JavaScript
new MutationObserver((mutations) => {
    // This code runs whenever the observed element changes
}).observe(document.querySelector("#search-container"), {
    childList: true, // Watch for added or removed child elements
    subtree: true // Watch children of children too
});

When new search results are rendered inside #search-container, the observer will trigger. Based on that idea, I wrote a small script to tie everything together. It watches for changes in the Pagefind UI, grabs the search query from the input field, and sends it to Umami.

Here is the final script I added to my search page:

JavaScript
// Informs TypeScript that `window.umami` can exist on the global scope.
declare global {
	interface Window {
		umami?: {
			track: (event: string, data?: Record<string, any>) => void;
		};
	}
}

/**
 * Initializes the search tracker by directly observing the Pagefind container.
 */
function setupSearchTracker() {
	const searchContainer = document.querySelector("#search");
	const searchInput = document.querySelector<HTMLInputElement>(
		".pagefind-ui__search-input"
	);

	// If the elements don't exist, do nothing.
	if (!searchContainer || !searchInput) {
		console.error(
			"Pagefind UI elements not found. Tracker not initialized."
		);
		return;
	}

	let lastTrackedQuery = "";

	const trackQuery = (query: string) => {
		if (query && window.umami) {
			window.umami.track("search", { query });
		}
	};

	// Create an observer to watch for when search results are added.
	const observer = new MutationObserver(() => {
		const currentQuery = searchInput.value.trim();

		// Track if the query is new and has a meaningful length.
		if (currentQuery.length > 2 && currentQuery !== lastTrackedQuery) {
			trackQuery(currentQuery);
			lastTrackedQuery = currentQuery;
		}
	});

	// Start observing the container for changes.
	observer.observe(searchContainer, {
		childList: true,
		subtree: true
	});
}

// Run the setup function.
setupSearchTracker();

The logic is straightforward. First, it finds the main search container and the search input field. Then it sets up a MutationObserver to watch the container.

Whenever the observer triggers, it reads the current text from the search input. To avoid sending too many events, I added a check to only track the query if it’s new and longer than two characters. The lastTrackedQuery variable makes sure I don’t send the same query over and over again as the user types.

If it’s a new query, the trackQuery function calls window.umami.track(). This sends a custom event named “search” to my Umami dashboard, along with the query itself as data.

Pagefind Integration

Now I can see all the search queries right in my dashboard. A neat little solution for a feature that wasn’t there by default.