So here’s the thing – I recently revamped my website to focus more on blogging, and suddenly realized people actually needed a way to find stuff. Shocking, I know.
Building a search feature for an Astro site isn’t rocket science, but there are definitely some gotchas along the way. Let me walk you through how I tackled this without relying on external APIs or fancy third-party services.
Why keep it local?
Look, my site isn’t exactly Wikipedia. With maybe 50 posts at most, spinning up Elasticsearch would be like using a sledgehammer to crack a nut. Plus, there’s something satisfying about keeping everything in-house – no API rate limits, no external dependencies breaking at 3 AM.
📏 Size Matters
Step one: Gathering your content
First things first – we need to tell Astro what content we actually want to search through. This is where Astro.glob
becomes your best friend.
// Get all pages (excluding dynamic routes and search page)
const pages = await Astro.glob('../pages/**/*.{astro,md,mdx}');
const posts = await Astro.glob('../content/posts/**/*.{md,mdx}');
// Process pages
const searchablePages = pages
.filter(page =>
!page.url?.includes('[') &&
!page.url?.includes('/api/') &&
page.url !== '/search'
)
.map(page => ({
title: page.frontmatter?.title || page.url?.split('/').pop() || 'Untitled',
url: page.url || '#',
type: 'page',
description: page.frontmatter?.description || ''
}));
// Process posts
const searchablePosts = posts.map(post => ({
title: post.frontmatter.title || 'Untitled Post',
url: `/posts/${post.file.split('/').pop()?.replace(/\.(md|mdx)$/, '')}`,
type: 'post',
description: post.frontmatter.description || '',
date: post.frontmatter.date ? new Date(post.frontmatter.date) : undefined
}));
Here’s what’s happening: Astro.glob
sucks up all files matching our pattern, then we filter out the stuff we don’t want (like dynamic routes with brackets). The mapping part transforms everything into a consistent format.
⚠️ Dynamic Route Gotcha
Building the search interface
Now for the fun part – actually building something users can interact with. I went with a hidden-by-default approach because search bars cluttering up the header annoy me.
<div class="flex items-center space-x-2">
<!-- Search Icon Button -->
<button
id="header-search-icon"
class="p-2 text-gray-600 hover:text-blue-600 transition focus:outline-none cursor-pointer"
aria-label="Search"
aria-haspopup="true"
aria-expanded="false"
>
<div class="flex items-center justify-center">
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="10.5" cy="10.5" r="7.5"></circle>
<line x1="21" y1="21" x2="15.9" y2="15.9"></line>
</svg>
</div>
</button>
<!-- Search Container -->
<div class="relative flex-grow">
<!-- Search Input -->
<input
type="text"
id="header-search-input"
placeholder="Search posts and pages..."
class="hidden w-full px-4 py-2 text-sm rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-150 bg-gray-100 shadow-sm"
/>
<!-- Search Results -->
<div
id="header-search-results"
class="hidden absolute z-50 w-full bg-white rounded-lg shadow-lg border border-gray-200 max-h-[70vh] overflow-y-auto mt-1 transition-all"
>
<!-- Results will be populated here -->
</div>
</div>
</div>
Pretty straightforward stuff. The key thing is keeping everything hidden until the user actually wants to search. Nobody likes interfaces that scream at them from the get-go.
The search logic (where the magic happens)
This is where things get interesting. We need to handle user input, filter results, and make everything feel snappy.
⚡ Performance Tip
<script is:inline define:vars={{ searchableContent }}>
const searchInput = document.querySelector('#header-search-input');
const searchResults = document.querySelector('#header-search-results');
const searchIcon = document.querySelector('#header-search-icon');
function highlightMatch(text, query) {
if (!query) return text;
const regex = new RegExp(`(${query})`, 'gi');
return text.replace(regex, '<mark class="bg-yellow-200">$1</mark>');
}
function formatDate(date) {
return date ? new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
}) : '';
}
function performSearch(query) {
if (!query) {
searchResults.classList.add('hidden');
return;
}
const results = searchableContent.filter(item =>
item.title.toLowerCase().includes(query.toLowerCase()) ||
(item.description && item.description.toLowerCase().includes(query.toLowerCase()))
).slice(0, 5);
searchResults.innerHTML = results.length === 0
? `<div class="p-3 text-sm text-gray-500">No results found</div>`
: results.map(item => `
<a
href="${item.url}"
class="block p-3 hover:bg-gray-100 transition ease-in-out duration-150 border-b last:border-b-0"
>
<div class="flex items-center gap-2 mb-1">
<span class="text-xs px-2 py-0.5 rounded ${
item.type === 'post'
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}">${item.type}</span>
${item.date ? `<span class="text-xs text-gray-500">${formatDate(item.date)}</span>` : ''}
</div>
<h3 class="text-sm font-medium">${highlightMatch(item.title, query)}</h3>
${item.description ? `<p class="text-xs text-gray-600 mt-1 line-clamp-2">${highlightMatch(item.description, query)}</p>` : ''}
</a>
`).join('');
searchResults.classList.remove('hidden');
}
// Show search input when icon is clicked
searchIcon.addEventListener('click', (e) => {
e.preventDefault();
searchInput.classList.toggle('hidden');
searchResults.classList.add('hidden');
if (!searchInput.classList.contains('hidden')) {
searchInput.focus();
searchIcon.setAttribute('aria-expanded', 'true');
}
});
// Debounce search to avoid excessive API calls
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
performSearch(e.target.value);
}, 300);
});
// Close search when clicking outside
document.addEventListener('click', (e) => {
if (!searchResults.contains(e.target) && e.target !== searchInput && !searchIcon.contains(e.target)) {
searchResults.classList.add('hidden');
searchInput.classList.add('hidden');
searchIcon.setAttribute('aria-expanded', 'false');
}
});
// Close on escape key (because people expect this)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
searchResults.classList.add('hidden');
searchInput.classList.add('hidden');
searchIcon.setAttribute('aria-expanded', 'false');
}
});
</script>
Let me break down what’s happening here. The performSearch
function does the heavy lifting – it filters content based on title and description matches, then builds HTML for the results. Simple string matching works surprisingly well for most use cases.
🎯 Regex Reality Check
highlightMatch
function uses regex to wrap search terms in
tags. It's basic but effective. For more complex highlighting, you might want to look into libraries like Fuse.js.The little details that matter
You know what separates good search from great search? The details. Like automatically focusing the input when someone clicks the search icon. Or closing the results when they press Escape (because that’s what people expect).
I also limited results to 5 items because nobody wants to scroll through 50 search results in a dropdown. If they can’t find what they need in the first few results, they’ll probably refine their search anyway.
♿ Accessibility Matters
aria-expanded
and aria-haspopup
attributes aren't just decoration.What I’d do differently next time
Honestly? This approach works great for small sites, but I’m already thinking about improvements. Fuzzy search would be nice – sometimes people misspell things. And maybe indexing the actual content, not just titles and descriptions.
But for a quick weekend project? This gets the job done beautifully.
The search is fast, doesn’t require external dependencies, and gives users exactly what they need. Sometimes the simple solution really is the best solution.
There you have it – a fully functional search feature for your Astro site that doesn’t require selling your soul to Big Tech APIs. The code is straightforward, the implementation is clean, and your users can actually find your content.
Now go build something awesome with it!
Stay up to date
Get notified when I publish something new, and unsubscribe at any time.