Day20 - Astro Series: Search Functionality

A beautiful gradient background with a heading: "Implementing Search Functionality"

Introduction

Previously I implemented an RSS feed via a static endpoint as a simple exercise; this time I go further by building a collection of data to implement a collection-based post search.

Defining the problem

As the number of posts grows, a way to search is needed: users enter query terms, match against post information (title, short description), and display the most relevant posts. From this, we derive the following requirements:

  1. A UI where users can enter queries
  2. A way to obtain the current collection data
  3. A system to fuzzy-match the input against all current data
  4. A UI to display the search results

Implementation

Putting it all together, the above issues require relatively frequent DOM manipulation and some complexity, so I’ll introduce a framework to help handle data and UI updates, avoiding low-level DOM operations. Here I use Preact (a lightweight React-like library), but you can pick whatever framework you prefer.

Step 1: Create the query input interface

Create a jsx component with an <input> that fetches the data to compare only on the user’s first focus, and toggles isLoading state.

function getPosts() {
setIsLoading(true);
return fetch('/search.json')
.then((res) => {
if (!res.ok) {
throw new Error('Network response was not ok');
}
return res.json();
})
.catch((err) => console.error(err))
.finally(setIsLoading(false));
}
async function handleInputFocus() {
setIsSearching(true);
if (!posts.length) {
setPosts(await getPosts());
}
}

Also, on blur, if the focus moved outside the search component, set isSearching to false.

function handleInputBlur(e) {
const { currentTarget } = e;
// 給瀏覽器時間去 Focus 下一個元素
requestAnimationFrame(() => {
if (!currentTarget.contains(document.activeElement)) {
setIsSearching(false);
}
});
}

Since this section isn’t shown on mobile sizes, you can load the client JavaScript only on devices with a width of at least 1024px:

<Search client:media="(min-width: 1024px)" />

Step 2: Create the data endpoint and fetch the data

Because getPosts expects a search.json at the site root, we need to create an endpoint that generates a JSON file of searchable data. For my blog example, the content includes each post’s slug, title, Traditional Chinese title, excerpt, and theme color.

search.json.ts
import { getCollection } from 'astro:content';
async function getPosts() {
const avaliablePosts = await getCollection('post', (post) => (import.meta.env.PROD ? !post.data.isDraft : true));
return avaliablePosts.map((post) => ({
slug: post.slug,
title: post.data.title,
headline: post.data.titleTC,
excerpt: post.data.excerpt,
themeColor: post.data.themeColor,
}));
}
export async function GET() {
return new Response(JSON.stringify(await getPosts()), {
status: 200,
headers: {
'content-type': 'application/json',
},
});
}

Step 3: Fuzzy matching the search results

On each input, if a Fuse instance hasn’t been created yet, create it and assign to fuseInstance; then calling its search method with the current query returns the best fuzzy-matched posts.

let fuseInstance;
function handleInputChange(e) {
if (!fuseInstance) {
fuseInstance = new Fuse(posts, {
includeScore: true,
shouldSort: true,
threshold: 0.5,
keys: [
{
name: 'titleTC',
weight: 1,
},
{
name: 'title',
weight: 1,
},
],
});
}
setResults(fuseInstance.search(e.target.value));
}

Step 4: Render the results to the UI

Basically you just need to render the results data to the screen.

Conclusion

I used Preact🔗 and Fuse.js🔗, but I aimed to focus on Astro itself, so some details are omitted; understanding the main concepts should be sufficient.

Further reading