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:
- A UI where users can enter queries
- A way to obtain the current collection data
- A system to fuzzy-match the input against all current data
- 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.
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
- Content Collections - Astro DOCS
- Dynamic Routes - Astro DOCS
- Day20 - Implementing Search Functionality - Same article also published in the iThome Ironman contest