Search with Supabase + Algolia's Autocomplete
3/28/2022
I’ve implemented Full Text Search feature on my side project Quill. Although I have a bit of experience(as in I work there) in Algolia, I didn’t use Algolia at this time. Algolia is the most powerful Search-as-a-service out there, but for this small side project, I don’t need that power yet. Also, I already have all the data stored in Supabase, and could just simply start using the search funcitonalities of PostgreSQL.
For the UI part, I could’ve used a simple <form>
and <input>
but instead went with Algolia’s Autocomplete library, which provides a great accessibility including keyboard navigation out-of-the-box.
GitHub - algolia/autocomplete: 🔮 Fast and full-featured autocomplete library
I have a table posts
with attributes including title
and body
. For now, I’ve decided to call two separate search calls, one for title
and another one for body
. I do not want to mix results, so that I can prioritize posts matched with title
to be listed first.
The image above shows you the flow from the frontend to the backend. I don’t have much to explain, so here are some snippets for you.
The frontend component ↓
<div id="autocomplete" />
import { autocomplete } from '@algolia/autocomplete-js';
import '@algolia/autocomplete-theme-classic';
// To learn more about debouncing with Autocomplete,
// Read https://www.algolia.com/doc/ui-libraries/autocomplete/guides/debouncing-sources/
function debouncePromise(fn, time) {
let timerId = undefined;
return function debounced(...args) {
if (timerId) {
clearTimeout(timerId);
}
return new Promise((resolve) => {
timerId = setTimeout(() => resolve(fn(...args)), time);
});
};
}
const debounced = debouncePromise((items) => Promise.resolve(items), 300);
// I use two sources, one for search results on `title`, and another one on `body`.
// The code is almost the same, so I extracted it as a function.
const getSource = ({ sourceId, mode }: { sourceId: string; mode: string }) => ({
sourceId,
onSelect({ item }) {
window.location.href = `/${item.slug}`;
},
async getItems({ query }) {
const response = await fetch(
`/search.json?mode=${mode}&query=${encodeURIComponent(query)}`
);
const json = await response.json();
json.result.forEach(({ slug }) => prefetch(`/${slug}`));
return json.result;
},
templates: {
...(mode === 'title'
? {
noResults() {
return 'No result for this query.';
}
}
: {}),
item({ item, createElement }) {
return createElement('div', {
dangerouslySetInnerHTML: {
__html: `
<div class="aa-ItemWrapper">
<div class="aa-ItemContent">
<div class="aa-ItemContentBody">
<div class="aa-ItemContentTitle">
${item.title}
</div>
<div class="aa-ItemContentSubtitle">
${item.excerpt}
</div>
</div>
</div>
</div>
`
}
});
}
}
});
// The real Autocomplete part.
autocomplete({
container: '#autocomplete',
placeholder: 'Search',
autoFocus: true,
getSources() {
return debounced([
getSource({ sourceId: 'postsByTitle', mode: 'title' }),
getSource({ sourceId: 'postsByBody', mode: 'body' })
]);
}
});
/search.json ↓
// The way to write serverless function varies according to platform.
// So this is the minimum business logic, excluding all the platform-related code.
function quote(s: string) {
return "'" + s.replace(/'/g, `\'`) + "'";
}
const searchQuery = query.get('query');
const mode = query.get('mode'); // 'title' | 'body'
const quotedQuery = searchQuery
.split(' ')
.map((word) => quote(word))
.join(' & ');
const targetAttribute =
mode === 'title' ? 'searchable_title' : mode === 'body' ? 'searchable_body' : null;
const { data } = await supabase
.from<Page>('pages')
.select('slug,title,excerpt')
.textSearch(targetAttribute, quotedQuery)
.eq('project_id', projectId);
return {
body: { result: data },
}