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!"
}

Before indexing a document with Typesense, create a Typesense collection. You can create a collection using either the Typesense API or the Typesense console:

Node.js

async function createTypesenseCollections() {
  // Every 'collection' in Typesense needs a schema. A collection only
  // needs to be created one time before you index your first document.
  //
  // Alternatively, use auto schema detection:
  // https://typesense.org/docs/latest/api/collections.html#with-auto-schema-detection
  const notesCollection = {
    'name': 'notes',
    'fields': [
      {'name': 'id', 'type': 'string'},
      {'name': 'owner', 'type': 'string' },
      {'name': 'text', 'type': 'string' }
    ]
  };

  await client.collections().create(notesCollection);
}

You can use Typesense with Cloud Functions to populate an index with the contents of each note and enable search. First, configure a Typesense client using your Admin API Key:

Node.js

// Initialize Typesense, requires installing Typesense dependencies:
// https://github.com/typesense/typesense-js
const Typesense = require("typesense");

// Typesense API keys are stored in functions config variables
const TYPESENSE_ADMIN_API_KEY = functions.config().typesense.admin_api_key;
const TYPESENSE_SEARCH_API_KEY = functions.config().typesense.search_api_key;

const client = new Typesense.Client({
  'nodes': [{
    'host': 'xxx.a1.typesense.net', // where xxx is the ClusterID of your Typesense Cloud cluster
    'port': '443',
    'protocol': 'https'
  }],
  'apiKey': TYPESENSE_ADMIN_API_KEY,
  'connectionTimeoutSeconds': 2
});

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.onNoteWritten = functions.firestore.document('notes/{noteId}').onWrite(async (snap, context) => {
  // Use the 'nodeId' path segment as the identifier for Typesense
  const id = context.params.noteId;

  // If the note is deleted, delete the note from the Typesense index
  if (!snap.after.exists) {
    await client.collections('notes').documents(id).delete();
    return;
  }

  // Otherwise, create/update the note in the the Typesense index
  const note = snap.after.data();
  await client.collections('notes').documents().upsert({
    id,
    owner: note.owner,
    text: note.text
  });
});

Once your data is indexed, you can use any of Typesense's API Clients to search through the data.

Web

// Create a Typesense Client using the search-only API key
const client = new Typesense.Client({
  'nodes': [{
    'host': 'xxx.a1.typesense.net', // where xxx is the ClusterID of your Typesense Cloud cluster
    'port': '443',
    'protocol': 'https'
  }],
  'apiKey': TYPESENSE_SEARCH_API_KEY,
  'connectionTimeoutSeconds': 2
});

// Search for notes with matching text
const searchParameters = {
  'q': query,
  'query_by': 'text'
};
const searchResults = await client.collections('notes')
  .documents()
  .search(searchParameters);
// ...

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 Scoped Search API Key, which adds specific filters to every query performed by a user.

Node.js

exports.getScopedApiKey = functions.https.onCall(async (data, context) => {
  // Ensure that the user is authenticated with Firebase Auth
  if (!(context.auth && context.auth.uid)) {
    throw new functions.https.HttpsError('permission-denied', 'Must be signed in!');
  }

  // Generate a scoped API key which allows the user to search ONLY
  // documents which belong to them (based on the 'owner' field).
  const scopedApiKey = client.keys().generateScopedSearchKey(
    TYPESENSE_SEARCH_API_KEY, 
    { 
      'filter_by': `owner:${context.auth.uid}`
    }
  );

  return {
    key: scopedApiKey
  };
});

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

Web

// Get a scoped TypeSense API key from our Callable Function
const getScopedApiKey = firebase.functions().httpsCallable('getScopedApiKey');
const scopedApiKeyResponse = await getScopedApiKey();
const apiKey = scopedApiKeyResponse.data.key;

// Create a Typesense Client
const client = new Typesense.Client({
  'nodes': [{
    'host': 'xxx.a1.typesense.net', // where xxx is the ClusterID of your Typesense Cloud cluster
    'port': '443',
    'protocol': 'https'
  }],
  'apiKey': apiKey,
  'connectionTimeoutSeconds': 2
});

// Search for notes with matching text
const searchParameters = {
  'q': query,
  'query_by': 'text'
};
const searchResults = await client.collections('notes')
  .documents()
  .search(searchParameters);
// ...

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

Limitations

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