How to Build Site Search with Astro, Qwik and Fuse.js
How to build a site search using Astro’s content collections, static endpoints and Qwik’s Astro integration with Fuse.js.
Feb 29th, 2024 10:03am by
Original photo by Emiliano Vittoriosi on Unsplash.
- 🚀 https://tns-astro-site-search.netlify.app
- ⚙️ https://github.com/PaulieScanlon/tns-astro-site-search
What Are Content Collections?
Astro has a convenient way to “bulk” query or transform content of similar types. In the case of my demo, this would apply to blog posts that are all written in MDX. All blog posts share the same template or layout and schema. Here’s the schema for blog posts.
// src/content/config.js
import { z, defineCollection } from 'astro:content';
export const collections = {
posts: defineCollection({
type: 'content',
schema: z.object({
draft: z.boolean().optional(),
audioFeedId: z.string().optional(),
base: z.string(),
title: z.string(),
tags: z.array(z.string()).optional(),
date: z.date(),
author: z.string(),
featuredImage: z.string(),
}),
}),
};
src in the repo here: src/content/config.js.
And for good measure, here’s the frontmatter for one of my blog posts (but all blog posts will use the same schema).
// src/content/posts/2024/02/the-qwik-astro-audiofeed-experiment.mdx
---
base: posts
title: The Qwik, Astro, Audiofeed Experiment
tags: [Qwik, Astro, Audiofeed, AI]
date: 2024-02-06
author: Paul Scanlon
featuredImage: https://res.cloudinary.com/www-paulie-dev/image/upload/v1707261626/paulie.dev/2024/02/get-started-with-qwik-astro_qtxmyq.jpg
---
src in the repo here: the-qwik-astro-audiofeed-experiment.mdx.
How to Query Astro’s Content Collections
To build site search functionality, I first need to query all the blog posts. I’ve achieved this using a static endpoint. I called itall-content.json.js and it lives in the src/pages directory. E.g.:
// src/pages/all-content.json.js
import { getCollection } from 'astro:content';
export const GET = async () => {
const posts = await getCollection('posts');
const search = posts
.filter((item) => item.data.draft !== true)
.map((data) => {
const {
slug,
data: { base, title, date },
} = data;
return {
date: date,
title: title,
base: base,
path: `/${base}/${slug}`,
};
})
.sort((a, b) => b.date - a.date);
return new Response(JSON.stringify({ search }));
};
getCollection('posts'), I do a quick filter to remove any blog posts that might be in draft mode, then return just the fields from the frontmatter that will be helpful for the search, and then sort them by date.
The result is stringified and returned as a standard Response.
Here’s what the result looks like.
[
{
date: 2024-02-22T00:00:00.000Z,
title: 'How to Build a Survey With KwesForms and Astro',
base: 'posts',
path: '/posts/2024/02/how-to-build-a-survey-with-kwesforms-and-astro'
},
{
date: 2024-02-06T00:00:00.000Z,
title: 'The Qwik, Astro, Audiofeed Experiment',
base: 'posts',
path: '/posts/2024/02/the-qwik-astro-audiofeed-experiment'
}
...
]
src in the repo here: src/pages/all-content.json.js.
This data provides everything I’ll need to start building the search component.
How to Query a Static Endpoint
In order to build the search component (coming next!) I first need to query the data from the static endpoint and pass it on to the search component. I query the data in my layout component, which is present in each page of my demo site, E.g.:
// src/pages/index.astro
---
import Layout from '../layouts/layout.astro';
---
<Layout>
<h1>Lorem ipsum</h1>
<p>...</p>
</Layout>
src in the repo here: src/pages/index.astro.
And here’s the layout component which makes a server-side request to the endpoint.
// src/layouts/layout.astro
---
import Search from '../components/search';
const content = await fetch(`${import.meta.env.PROD ? 'https://tns-astro-site-search.netlify.app' : 'http://localhost:4321'}/all-content.json`);
const { search } = await content.json();
---
<html lang='en'>
<head>...</head>
<body>
<header>
<Search data={search} />
</header>
<main>
<slot />
</main>
</body>
</html>
PROD is true, the URL to the static endpoint will be https://tns-astro-site-search.netlify.app/all-content.json, and while in development the localhost URL is used.
Provided I am able to query the search data, I can pass it on to my search component via the data prop.
You can see the src in the repo here: src/layouts/layout.astro.
Building the Search Component
There are two additional dependencies to install in order to build the search component. They are as follows.
npm install fuse.js @qwikdev/astro
Fuse.js
I’ve used Fuse.js to help with the “fuzzy search.” Keyboard strokes are captured and passed through Fuse.js. If any of the letters or words match a title or date, Fuse.js will return the item.Qwik
I use Qwik’s Astro integration to help manage client-side state. Qwik is more lightweight than React and is less verbose than vanilla JavaScript. The remaining steps will cover how to set up the search and filtering. I’ve created a simple example, which you can preview here: https://tns-astro-site-search.netlify.app/simple. Thesrc can be found here: src/components/simple-search.jsx.
Note: The example used in my demo contains a lot of additional CSS and JavaScript to handle the modal, which isn’t required to create search functionality.
Search Component: Step 1
The first step is to create the search component and return an HTML input. Add anonInput$ event handler and create a function named handleInput to capture the keystrokes.
// src/components/simple-search.jsx
import { component$, $ } from '@builder.io/qwik';
const Search = component$(({ data }) => {
const handleInput = $(async (event) => {
const {
target: { value },
} = event;
});
return (
<div>
<input type='text' placeholder='Search' onInput$={handleInput} />
</div>
);
});
export default Search;
Search Component: Step 2
Next importuseSignal, and create two new constants to hold the values for all the data and the filtered data.
// src/components/simple-search.jsx
- import { component$, $ } from '@builder.io/qwik';
+ import { component$, $, useSignal } from '@builder.io/qwik';
const Search = component$(({ data }) => {
+ const all = useSignal(data);
+ const filtered = useSignal(data);
const handleInput = $(async (event) => {
const {
target: { value },
} = event;
});
return (
<div>
<input type='text' placeholder='Search' onInput$={handleInput} />
</div>
);
});
export default Search;
Search Component: Step 3
Next import and initialise Fuse.js. The config for Fuse.js accepts the value from theuseSignal const (all.value) and will apply a fuzzy filter threshold of 0.5 when any input values match values for the title or date.
fuse.search can be used to filter out any items from the array that don’t meet the config parameters, and a new array is returned. I’ve called this new array “results.”
// src/components/simple-search.jsx
import { component$, $, useSignal } from '@builder.io/qwik';
const Search = component$(({ data }) => {
const all = useSignal(data);
const filtered = useSignal(data);
const handleInput = $(async (event) => {
const {
target: { value },
} = event;
+ const FuseModule = await import('fuse.js');
+ const Fuse = FuseModule.default;
+ const fuse = new Fuse(all.value, {
+ threshold: 0.5,
+ keys: ['title', 'date'],
+ });
+ const results = fuse.search(value).map((data) => {
+ const { item: { base, path, title, date } } = data;
+ return {
+ title,
+ date,
+ path,
+ base,
+ };
});
});
return (
<div>
<input type='text' placeholder='Search' onInput$={handleInput} />
</div>
);
});
export default Search;
Search Component: Step 4
The next step is to add anif statement. If there’s a value captured from the HTML input, then I set useSignal filtered.value equal to the results, and if there’s no value captured from the HTML input then I set the useSignal filtered.value equal to the all.value.
This will either return a filtered list, or the whole list.
// src/components/simple.search.jsx
import { component$, $, useSignal } from '@builder.io/qwik';
const Search = component$(({ data }) => {
const all = useSignal(data);
const filtered = useSignal(data);
const handleInput = $(async (event) => {
...
+ if (value) {
+ filtered.value = results;
+ } else {
+ filtered.value = all.value;
+ }
});
return (
<div>
<input type='text' placeholder='Search' onInput$={handleInput} />
</div>
);
});
export default Search;
Search Component: Step 5
The final step is to iterate over thefiltered.value (if it has length) and return a list of items. If there are no results, then I return null.
// src/components/simple-search.jsx
import { component$, $, useSignal } from '@builder.io/qwik';
const Search = component$(({ data }) => {
const all = useSignal(data);
const filtered = useSignal(data);
const handleInput = $(async (event) => {
...
});
return (
<div>
<input type='text' placeholder='Search' onInput$={handleInput} />
+ <ul>
+ {filtered.value.length > 0
+ ? filtered.value.map((data, index) => {
+ const { path, title } = data;
+ return (
+ <li key={index}>
+ <a href={path}>{title}</a>
+ </li>
+ );
+ })
+ : null}
+ </ul>
</div>
);
});
export default Search;
Finished
And that’s it, that’s all the principles behind how to query data using Astro’s content collections, how to make the data available using a static endpoint, and then implement fuzzy-search using Fuse.js and Qwik’s Astro integration to manage the client-side state. I’ve used this same approach on my site, and it’s working out pretty well so far!
YOUTUBE.COM/THENEWSTACK
Tech moves fast, don't miss an episode. Subscribe to our YouTube
channel to stream all our podcasts, interviews, demos, and more.