← Back to blog

Full-text search in Firestore with Algolia Firebase Extension

Mais Alheraki

Open Source Engineer

16th November, 2022

Need a custom tool? Invertase can help

Tired of investing in off-the-shelf software that doesn't quite fit your business? Invertase can build solutions tailored to your exact needs. Tell us what you're looking for here and we'll be in touch.

Introduction

In a digital world full of content, it’s essential to have the ability to search for what matters to you. Full-text search is a feature that allows users to search content across your site or app. Furthermore, by giving this power to users, you’re ensuring they can access whatever they want using a few words, instantly, which makes their experience more delightful.

In this article, you will learn about Algolia, one of the popular providers of search services, along with how to use it to do a full-text search in the Firestore database.

Full-text search in Firestore

Does Firestore support native indexing?

Firestore does not provide a native solution for indexing your database, and it is not practical to do the search client-side. The suggested solution by the official docs is to use one of the many search providers: Algolia, Elastic, or Typesense. You can read about it here.

To integrate with any of these providers, you would need to write some server-side code yourself using Cloud Functions, which sounds like a lot of work, especially for front-end developers. Luckily, there’s an easier way to keep Firestore synced with Algolia, which you will explore in the following sections.

Get to know Algolia

Algolia is a popular “Search as a service” provider, which offers a variety of advanced searching features. Along with its rich API, Algolia has wide support for client-side apps through its official client SDKs for web and mobile.

By indexing your database, you can use Algolia’s SDKs to provide a seamless searching experience to your users, on any platform!

Keeping Firestore in sync with Algolia

You need to make sure your Firestore searchable content stays in sync with Algolia indices, which is straightforward thanks to Firebase extensions. No code is required!

First, install the Search with the Algolia extension. The installation process could be done either using the Firebase CLI or console.

Install the Search with Algolia extension

Before installing the extension, you need to have an Algolia account and app setup, which is not covered by this article. Pause reading here, click here to create a new Algolia app, then come back to continue.

Now that you’re ready, click here to install the extension. Bear in mind, just like with all Firebase extensions, your project needs to be upgraded to the Blaze plan.

You can check the Google Cloud resources used by this extension through its page on extension.dev.

Search with Algolia extension configurations

If you would like to follow with the sample in the following sections, use the same values mentioned here to do your configuration.

The Collection Path field is the Firestore collection that you wish to keep synced with Algolia. The Indexable Fields are the fields in your documents that this extension will sync to Algolia, any field that is not in here will not be indexed. If left blank, all fields will be indexed. Leave Force Sync to “No”, which is the default.

There are 3 fields for Algolia-specific configurations:

Algolia Index Name: this can be different than the collection name. This name must match an index you create in Algolia. For convenience, it might be a good idea to keep it the same as your collection name, in this case, it’s movies.

Algolia Application Id: copy it from the Algolia dashboard.

Algolia API Key: On the same page, go to the tab “All API Keys” tab, create a new key with ACLs as in the image below, and choose the relevant indices. Use this newly created key in the extension configuration.

Finally, the Transformer Function Name is optional, leave it empty as it’s not going to be covered here, and you can go with any location for the function.

Syncing multiple collections in the same database

You may have noticed that you could only declare a single collection name when you installed the extension. In fact, this installation instance will only support a single collection.

What if you would like to sync multiple collections? This is possible by installing multiple instances. If you click the installation link again, it will prompt you to install a new instance as shown in the following image.

Sample use-case: searching for movies

The extension will keep your Firestore searchable collections up-to-date with Algolia indices. Each time a new document is added, deleted, or updated in Firestore, it will be reflected in Algolia immediately.

Now that the first step is done, let’s create a simple app to test the integration.

The data used in this sample is 1000 movie information imported from IMDP, find it here. You can import it to Firestore by following this guide, find the exported Firestore version of the movies collection here.

Post-install step: upload existing records

If you already have data in your collection, after the extension has been installed, you might have noticed that your index in Algolia is still empty. This is because the extension will start monitoring new changes. Existing records won’t be uploaded automatically.

However, there is a script from Algolia to help upload existing documents to the desired index. These instructions are already provided in the post-install guide for the extension. Go to the Extensions tab in your console, click on “Search with Algolia”, then go to the “How this extension works” tab.

After importing, you will be able to see the movies from Firestore in Algolia’s index. Note that the objectID is the same as the documentId in Firestore.

Algolia UI components

At this point, you’ve installed the extension and have the Algolia index ready. The next step is to create a simple search UI to see how everything gets nicely together.

To get started, create a new NextJS app. This tutorial will use NextJS for the front end, yet Algolia has similar UI libraries for vanilla JS, iOS, Android, Angular, Vue, and Flutter.

npx create-next-app@latest --typescript

Next, install the dependencies:

npm i --save algoliasearch
npm i --save react-instantsearch-hooks-web

Go to pages/index.tsx and clear all content, then paste the following code:

import { useEffect, useState } from 'react';

import algoliasearch from 'algoliasearch/lite';
import {
  InstantSearch,
  SearchBox,
  RefinementList,
  InfiniteHits,
} from 'react-instantsearch-hooks-web';

const searchClient = algoliasearch(
  process.env.NEXT_PUBLIC_ALGOLIA_APP_ID!,
  process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY!
);

export default function Home() {
  return (
    <div className='container'>
      <InstantSearch
        searchClient={searchClient}
        indexName='movies'
      ></InstantSearch>
    </div>
  );
}

The component InstantSearch from Algolia will take your, and the index name movies. The searchClient should be initialized using the same App ID and the API key you used to install the extension.

Add the search & results list (each search result is called Hit) components under InstantSearch:

<div className='container'>
  <InstantSearch searchClient={searchClient} indexName='movies'>
    <div className='container-search-hits'>
      <SearchBox placeholder='Search for movies' />
      <InfiniteHits hitComponent={MovieHitComponent} />
    </div>
  </InstantSearch>
</div>

The InifniteHits will render the results coming from Algolia as a list, which takes a hitComponent, to render each item. Create a new file hit.tsx, and add the following code:

import { Highlight } from 'react-instantsearch-hooks-web';
import { MovieHit } from '../movie_type';

type HitProps = {
  hit: MovieHit;
  onClick: Function;
};

type GenreProps = {
  genre?: string;
};

function GenreLabel({ genre }: GenreProps): JSX.Element {
  return <div className='genre-label'>{genre}</div>;
}

export default function MovieComponent({
  hit,
  onClick,
}: HitProps): JSX.Element {
  return (
    <div onClick={() => onClick(hit)}>
      <Highlight attribute='primaryTitle' hit={hit} />
      <p> {hit.startYear}</p>
      <div className='genres-row'>
        {hit.genres &&
          hit.genres.map((genre: string) => (
            <GenreLabel key={genre} genre={genre} />
          ))}
      </div>
    </div>
  );
}

The MovieHit is a type that has the searchable properties of movies in Algolia, in addition to the Hit fields which come by default with all types of Algolia hits. Create a new file named movie_type.ts:

import { Hit } from 'instantsearch.js';

// New type
type MovieHit = {
  primaryTitle: string;
  originalTitle: string;
  genres: string[];
  startYear: number;
} & Hit;

Back to the Home component, add the following:

// New imports
import type { Hit } from 'instantsearch.js';
import MovieComponent from '../hit';

export default function Home() {
  // Add from here
  const [selectedMovie, setSelectedMovie] = useState<MovieHit | undefined>();

  const MovieHitComponent = ({ hit }: { hit: Hit<MovieHit> }) => {
    return <MovieComponent hit={hit} onClick={setSelectedMovie} />;
  };
  // To here

  return (
    <div className='container'>
      <InstantSearch searchClient={searchClient} indexName='movies'>
        <div className='container-search-hits'>
          <SearchBox placeholder='Search for movies' />
          <InfiniteHits hitComponent={MovieHitComponent} />
        </div>
      </InstantSearch>
    </div>
  );
}

The MoviesHitComponent has an onClick event handler, which takes the setSelectedMovie hook to update the currently selected movie whenever a user clicks on a new item. The Hit coming from Algolia might not contain all the properties you need to handle the onClick event, for example, to navigate to a new page. Therefore, let’s use a new hook that will fetch the movies from Firestore whenever the selectedMovie is updated:

export default function Home() {
  const [selectedMovie, setSelectedMovie] = useState<MovieHit | undefined>();

  const MovieHitComponent = ({ hit }: { hit: Hit<MovieHit> }) => {
    return <MovieComponent hit={hit} onClick={setSelectedMovie} />;
  };

	// Add from here
  useEffect(() => {
    if (selectedMovie) {
			// Whenever the selectedMovie is updated, we fetch the movie by its ID from Firestore.
      getMovieDoc(selectedMovie.objectID).then((movie) => {
        console.log(movie);
      });
    }
  }, [selectedMovie]);
	// To here

  return (
    <div className='container'>
      <InstantSearch searchClient={searchClient} indexName='movies'>
        <div className='container-search-hits'>
          <SearchBox placeholder='Search for movies' />
          <InfiniteHits hitComponent={MovieHitComponent} />
        </div>
      </InstantSearch>
    </div>
  );
}

Create a new file named get_movie.ts, and add the following:

import { initializeApp } from 'firebase/app';
import { getFirestore, getDoc, doc } from 'firebase/firestore';
import { firebaseConfig } from './firebase_config';
import { movieConverter } from '../types/movie';

const app = initializeApp(firebaseConfig);
const firestore = getFirestore(app);

export async function getMovieDoc(movieId: string) {
  const ref = doc(firestore, `movies/${movieId}`).withConverter(movieConverter);
  const docSnap = await getDoc(ref);

  if (docSnap.exists()) {
    return docSnap.data();
  } else {
    console.error('No such document!');
  }
}

The movieConverter is a method that serializes the JSON object from Firestore to a Movie object and vice versa. Read more about Firestore converters here.

Add the movieConverter and Movie type:

import { DocumentSnapshot, SnapshotOptions } from 'firebase/firestore';

export class Movie {
  primaryTitle: string;
  originalTitle: string;
  genres: string[];
  startYear: number;

  constructor(
    primaryTitle: string,
    originalTitle: string,
    genres: string[],
    startYear: number
  ) {
    this.primaryTitle = primaryTitle;
    this.originalTitle = originalTitle;
    this.genres = genres;
    this.startYear = startYear;
  }
}

// Firestore data converter
export const movieConverter = {
  toFirestore: (movie: Movie) => {
    return {
      primaryTitle: movie.primaryTitle,
      originalTitle: movie.originalTitle,
      genres: movie.genres,
      startYear: movie.startYear,
    };
  },
  fromFirestore: (snapshot: DocumentSnapshot, options: SnapshotOptions) => {
    const data = snapshot.data(options);
    return new Movie(
      data!.primaryTitle,
      data!.originalTitle,
      data!.genres,
      data!.startYear
    );
  },
};

and voila 🎉  you’ve built a search feature in a short time!

The final result will look like this:

Get the full source code

You can get the full source code of this sample on GitHub here.

Going further: Kara’s Coffee

Kara’s Coffee is a full e-commerce app that uses Algolia to search across all items on the website. If you would like to discover more features and use cases for Firebase extensions, check-out Kara’s Coffee here.

Mais Alheraki

Open Source Engineer

Categories

Tags