Full-text search

Most apps allow users to search app content. For example, you may want to search for posts containing a certain word or notes you've written about a specific topic.

Cloud Firestore doesn't support native indexing or search for text fields in documents. Additionally, downloading an entire collection to search for fields client-side isn't practical.

To enable full text search of your Cloud Firestore data, use a dedicated third-party search service. These services provide advanced indexing and search capabilities far beyond what any simple database query can offer.

Before continuing, research then choose one of the search providers below:

Solution: External search service

Consider a note-taking app where each note is a document:

// /notes/${ID}
{
  owner: "{UID}", // Firebase Authentication's User ID of note owner
  text: "This is my first note!"
}

You can use Algolia with Cloud Functions to populate an index with the contents of each note and enable search. First, configure an Algolia client using your App ID and API key:

Node.js

// Initialize Algolia, requires installing Algolia dependencies:
// https://www.algolia.com/doc/api-client/javascript/getting-started/#install
//
// App ID and API Key are stored in functions config variables
const ALGOLIA_ID = functions.config().algolia.app_id;
const ALGOLIA_ADMIN_KEY = functions.config().algolia.api_key;
const ALGOLIA_SEARCH_KEY = functions.config().algolia.search_key;

const ALGOLIA_INDEX_NAME = 'notes';
const client = algoliasearch(ALGOLIA_ID, ALGOLIA_ADMIN_KEY);

Next, add a function that updates the index each time a note is written:

Node.js

// Update the search index every time a blog post is written.
exports.onNoteCreated = functions.firestore.document('notes/{noteId}').onCreate((snap, context) => {
  // Get the note document
  const note = snap.data();

  // Add an 'objectID' field which Algolia requires
  note.objectID = context.params.noteId;

  // Write to the algolia index
  const index = client.initIndex(ALGOLIA_INDEX_NAME);
  return index.saveObject(note);
});

Once your data is indexed, you can use any of Algolia's integrations for Apple, Android, or Web to search through the data.

Web

var client = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_SEARCH_KEY);
var index = client.initIndex('notes');

// Perform an Algolia search:
// https://www.algolia.com/doc/api-reference/api-methods/search/
return index
  .search({
    query
  })
  .then(function(responses) {
    // Response from Algolia:
    // https://www.algolia.com/doc/api-reference/api-methods/search/#response-format
    console.log(responses.hits);
  });

Adding Security

The original solution works well if all notes are searchable by all users. However, many use cases require securely limiting results. For these use cases, you can add an HTTP Cloud Function that generates a Secured API Key, which adds specific filters to every query performed by a user.

Node.js

// This complex HTTP function will be created as an ExpressJS app:
// https://expressjs.com/en/4x/api.html
const app = require('express')();

// We'll enable CORS support to allow the function to be invoked
// from our app client-side.
app.use(require('cors')({origin: true}));

// Then we'll also use a special 'getFirebaseUser' middleware which
// verifies the Authorization header and adds a `user` field to the
// incoming request:
// https://gist.github.com/abeisgoat/832d6f8665454d0cd99ef08c229afb42
app.use(getFirebaseUser);

// Add a route handler to the app to generate the secured key
app.get('/', (req, res) => {
  // @ts-ignore
  const uid = req.user.uid;

  // Create the params object as described in the Algolia documentation:
  // https://www.algolia.com/doc/guides/security/api-keys/#generating-api-keys
  const params = {
    // This filter ensures that only documents where owner == uid will be readable
    filters: `owner:${uid}`,
    // We also proxy the uid as a unique token for this key.
    userToken: uid,
  };

  // Call the Algolia API to generate a unique key based on our search key
  const key = client.generateSecuredApiKey(ALGOLIA_SEARCH_KEY, params);

  // Then return this key as {key: '...key'}
  res.json({key});
});

// Finally, pass our ExpressJS app to Cloud Functions as a function
// called 'getSearchKey';
exports.getSearchKey = functions.https.onRequest(app);

If you use this function to provide the Algolia search key, the user can only search for notes where the author field is exactly equal to their user ID.

Web

// Use Firebase Authentication to request the underlying token
return firebase.auth().currentUser.getIdToken()
  .then(function(token) {
    // The token is then passed to our getSearchKey Cloud Function
    return fetch('https://us-central1-' + PROJECT_ID + '.cloudfunctions.net/getSearchKey/', {
        headers: { Authorization: 'Bearer ' + token }
    });
  })
  .then(function(response) {
    // The Fetch API returns a stream, which we convert into a JSON object.
    return response.json();
  })
  .then(function(data) {
    // Data will contain the restricted key in the `key` field.
    client = algoliasearch(ALGOLIA_APP_ID, data.key);
    index = client.initIndex('notes');

    // Perform the search as usual.
    return index.search({query});
  })
  .then(function(responses) {
    // Finally, use the search 'hits' returned from Algolia.
    return responses.hits;
  });

It's not necessary to fetch a search key for every query. You should cache the fetched key, or the Algolia client, to speed up searching.

Limitations

  • Pricing: Algolia offers many service tiers, make sure to visit the pricing page and choose the option that best fits your needs.