Implementing Astro Search Functionality

Adding an Astro Search Bar

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}');
The Astro.glob function fetches files that match the specified pattern, allowing us to collect all our blog posts and pages. We then filter out dynamic routes and the search page itself to ensure the search only accesses static content.

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>
The UI includes a button for the search icon and an input field that is hidden by default. When activated, it allows users to search. The results are dynamically created based on user input.

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

Disclaimer: This post is for personal use, but I hope it can also help others. I'm sharing my thoughts and experiences here.
If you have any insights or feedback, please reach out!
Note: Some content on this site may have been formatted using AI.

© 2024 Pavlin

Instagram GitHub