Paul is a Senior Software Engineer, Independent Developer Advocate and Technical Writer. More from Paul can be found on his site, paulie.dev.
Read more from Paul Scanlon
In this post, I’ll explain how to build a site search using Astro’s content collections, static endpoints and Qwik’s Astro integration with Fuse.js.
I’ve prepared a demo site and open source repo, which you’ll find at the following links:
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
// 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(),
}),
}),
};
|
You can see the 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).
1
2
3
4
5
6
7
8
9
10
|
//
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
---
|
You can see the src
in the repo here: the-qwik-astro-audiofeed-experiment.mdx.
To build site search functionality, I first need to query all the blog posts. I’ve achieved this using a static endpoint. I called it all-content.json.js
and it lives in the src/pages
directory. E.g.:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// 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
}));
};
|
Once I’ve queried all the blog posts using 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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
[
{
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'
}
...
]
|
You can see the src
in the repo here: src/pages/all-content.json.js.
This data provides everything I’ll need to start building the search component.
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.:
1
2
3
4
5
6
7
8
9
10
|
//
src/
pages/
index
.
astro
---
import
Layout
from
'../layouts/layout.astro';
---
<
Layout
>
<
h1
>
Lorem
ipsum
</
h1
>
<
p
>...
</
p
>
</
Layout
>
|
You can see the src
in the repo here: src/pages/index.astro.
And here’s the layout component which makes a server-side request to the endpoint.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
//
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
>
|
One thing to point out here is the URL used in the fetch. If the site is deployed and 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.
There are two additional dependencies to install in order to build the search component. They are as follows.
1
|
npm
install
fuse
.
js
@
qwikdev/
astro
|
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.
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. The src
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.
The first step is to create the search component and return an HTML input. Add an onInput$
event handler and create a function named handleInput
to capture the keystrokes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// 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;
|
Next import useSignal
, and create two new constants to hold the values for all the data and the filtered data.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
//
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;
|
Next import and initialise Fuse.js. The config for Fuse.js accepts the value from the useSignal
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.”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
//
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;
|
The next step is to add an if
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
//
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;
|
The final step is to iterate over the filtered.value
(if it has length) and return a list of items. If there are no results, then I return null
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
//
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;
|
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!