Recently, I updated my website to focus more on blogs, which included adding a search bar to enable users to navigate through my content.
In this short post, I’ll walk you through how to add your own search functionality in Astro!
Adding Search Functionality
For this implementation, I opted against using an external API since my website’s content is relatively small at this stage. Instead, we’ll run the search locally, allowing us to streamline the process and maintain control over the data we are searching through.
Gathering Your Content
The first step is to collect all the pages and posts we want to include in our search. We’ll use Astro’s glob
feature to retrieve them from the local filesystem while excluding dynamic routes and the search page itself. Here’s how you can do it:
// 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
}));
Explanation
const pages = await Astro.glob('../pages/**/*.{astro,md,mdx}');
const posts = await Astro.glob('../content/posts/**/*.{md,mdx}');
Designing the UI Component
Next, we need to design the user interface for our search bar. This includes an input field for users to enter their queries and a results container to display matching results. Below is the basic structure of the UI:
"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" stroke-linecap="round" stroke-linejoin="round">
<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={placeholder}
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>
Implementing Search Functionalitty
Now that we have our UI in place, let’s implement the logic for handling the search functionality.
We’ll capture user input, filter the searchable content, and then display the results appropriatel
<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 and focus on it
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
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
performSearch(e.target.value);
}, 300);
});
// Close search results 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
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
searchResults.classList.add('hidden');
searchInput.classList.add('hidden');
searchIcon.setAttribute('aria-expanded', 'false');
}
});
</script>
Explanation
This script captures the user input, processes the search, and displays results dynamically. It highlights matches, formats dates, and provides functionality for showing and hiding the search UI based on user interaction. The debounce mechanism optimizes performance by limiting search operations while the user is typing.
Congratulations! You’ve successfully implemented a local search bar in your Astro-powered website. Feel free to customize the styles and functionality to better suit your needs.
I truly hope this article has helped you in your development journey! If you have any questions or would like to share your own experiences, please feel free to leave a comment below.
Happy coding! :D