Firestore with NextJS

Cloud Firestore is a cloud-hosted, NoSQL database that your iOS, Android, and web apps can access directly via native SDKs.

Initialize Cloud Firestore

Get Firebase configuration from web console and then use it to initialize the app in your web frontend:

// Initialize Cloud Firestore through Firebase
firebase.initializeApp({
  apiKey: '### FIREBASE API KEY ###',
  authDomain: '### FIREBASE AUTH DOMAIN ###',
  projectId: '### CLOUD FIRESTORE PROJECT ID ###'
});

var db = firebase.firestore();

A collection contains documents and nothing else. Documents contain data as property-value pairs. The names of documents within a collection are unique. A document can have a subcollection itself. Documents are schema-less, meaning that within a collection each document have different properties. However, it’s a good idea to use the same fields and data types across multiple documents, so that you can query the documents more easily.

Data Types

  • Array. Cannot contain array itself
  • Boolean
  • Bytes
  • Date and time ()
  • Floating point number (Cloud Firestore always stores numbers as doubles)
  • Geographical point
  • Integer
  • Map
  • Null
  • Reference. By path elements such as projects/[PROJECT_ID]/databases/[DATABASE_ID]/documents/[DOCUMENT_PATH]
  • Text string
var docData = {
    stringExample: "Hello world!",
    booleanExample: true,
    numberExample: 3.14159265,
    dateExample: firebase.firestore.Timestamp.fromDate(new Date("December 10, 1815")),
    arrayExample: [5, true, "hello"],
    nullExample: null,
    objectExample: {
        a: 5,
        b: {
            nested: "foo"
        }
    }
};

SDK

Cloud Firestore supports mobile/web SDKs and server client libraries which implement best practices for you and make it easier to access your database. Clients connect directly to your Cloud Firestore database without intermediary server. The mobile and web SDKs also support realtime updates and offline data persistence.

The server client libraries (aka Firebase Admin SDK) create a privileged Cloud Firestore environment with full access to your database. Requests are not evaluated against your Cloud Firestore security rules, instead they are secured via Identity and Access Management (IAM).

References

A reference is a lightweight object that just points to a location in your database. You can create a reference whether or not data exists there, and creating a reference does not perform any network operations.

// Reference a document
let docRef = db.collection('users').doc('alovelace');

// Reference a collection
let usersRef = = db.collection('users');

// use a path to a document
let alovelaceDocumentRef = db.doc('users/alovelace');

// reference sub collection
var messageRef = db.collection('rooms').doc('roomA').collection('messages').doc('message1');

Adding documents

You do not need to “create” or “delete” collections. After you create the first document in a collection, the collection exists. If you delete all of the documents in a collection, it no longer exists.

Option 1: With auto-generated ID (collection.add)

Cloud Firestore auto-generated IDs do not provide any automatic ordering. If you want to be able to order your documents by creation date, you should store a timestamp as a field in the documents.

db.collection("users").add({
    first: "Ada",
    last: "Lovelace",
    born: 1815
})
.then((docRef) => {
    console.log("Document written with ID: ", docRef.id);
})
.catch((error) => {
    console.error("Error adding document: ", error);
});

Option 2: Create a ref (collection.doc) then ref.set

// Add a new document with a generated id.
var newCityRef = db.collection("cities").doc();

// later...
newCityRef.set(data);

With specific ID (collection.doc(id))

db.collection("cities").doc("new-city-id").set(data);

Create or overwrite document (doc.set)

db.collection("cities").doc("LA").set({
    name: "Los Angeles",
    state: "CA",
    country: "USA"
})
.then(() => {
    console.log("Document successfully written!");
})
.catch((error) => {
    console.error("Error writing document: ", error);
});

Create or update document (doc.set with merge)

set with merge will update fields in the document or create it if it doesn’t exists. For set you always have to provide document-shaped data:

var cityRef = db.collection('cities').doc('BJ');

var setWithMerge = cityRef.set(
  {a: {b: {c: true}}},
  {merge: true}
);

Update document and fail if exists (doc.update)

With update you can also use field paths for updating nested values:

update({
  'a.b.c': true
})

Important difference between this dot notation and without: With dot notation you update a single nested field without overwriting other nested field. If you update a nested field without dot notation, you will overwrite the entire map field.

Add/Remove elements in an array (arrayUnion/arrayRemove)

arrayUnion() adds elements to an array but only elements not already present. arrayRemove() removes all instances of each given element.

var washingtonRef = db.collection("cities").doc("DC");

// Atomically add a new region to the "regions" array field.
washingtonRef.update({
    regions: firebase.firestore.FieldValue.arrayUnion("greater_virginia")
});

// Atomically remove a region from the "regions" array field.
washingtonRef.update({
    regions: firebase.firestore.FieldValue.arrayRemove("east_coast")
});

Server Timestamp

Let server set timestamp when updating a document. When updating multiple timestamp fields inside of a transaction, each field receives the same server timestamp value.

var docRef = db.collection('objects').doc('some-id');

// Update the timestamp field with the value from the server
var updateTimestamp = docRef.update({
    timestamp: firebase.firestore.FieldValue.serverTimestamp()
});

Increment numeric value

var washingtonRef = db.collection('cities').doc('DC');

// Atomically increment the population of the city by 50.
washingtonRef.update({
    population: firebase.firestore.FieldValue.increment(50)
});

Delete documents

Deleting a document does not delete its subcollections.

db.collection("cities").doc("DC").delete().then(() => {
    console.log("Document successfully deleted!");
}).catch((error) => {
    console.error("Error removing document: ", error);
});

Delete fields

var cityRef = db.collection('cities').doc('BJ');

// Remove the 'capital' field from the document
var removeCapital = cityRef.update({
    capital: firebase.firestore.FieldValue.delete()
});

Delete collections

Deleting collections from a Web client is not recommended. Instead do so only from a trusted server environment

async function deleteCollection(db, collectionPath, batchSize) {
  const collectionRef = db.collection(collectionPath);
  const query = collectionRef.orderBy('__name__').limit(batchSize);

  return new Promise((resolve, reject) => {
    deleteQueryBatch(db, query, resolve).catch(reject);
  });
}

async function deleteQueryBatch(db, query, resolve) {
  const snapshot = await query.get();

  const batchSize = snapshot.size;
  if (batchSize === 0) {
    // When there are no documents left, we are done
    resolve();
    return;
  }

  // Delete documents in a batch
  const batch = db.batch();
  snapshot.docs.forEach((doc) => {
    batch.delete(doc.ref);
  });
  await batch.commit();

  // Recurse on the next process tick, to avoid
  // exploding the stack.
  process.nextTick(() => {
    deleteQueryBatch(db, query, resolve);
  });
}

Delete data using Firebase CLI

firebase firestore:delete [options] <<path>>

Reading

Two approaches:

  1. Call a method to get the data or
  2. set a listener to receive data-change events, then Cloud Firestore sends your listener an initial snapshot of the data, and then another snapshot each time the document changes

Get single document / check if document exists (ref.get())

var docRef = db.collection("cities").doc("SF");

docRef.get().then((doc) => {
    if (doc.exists) {
        console.log("Document data:", doc.data());
    } else {
        // doc.data() will be undefined in this case
        console.log("No such document!");
    }
}).catch((error) => {
    console.log("Error getting document:", error);
});

Get from database or cache

By default, a get call will attempt to fetch the latest document snapshot from your database. On platforms with offline support, the client library will use the offline cache if the network is unavailable or if the request times out.

var docRef = db.collection("cities").doc("SF");

// Valid options for source are 'server', 'cache', or
// 'default'. See https://firebase.google.com/docs/reference/js/firebase.firestore.GetOptions
// for more information.
var getOptions = {
    source: 'cache'
};

// Get a document, forcing the SDK to fetch from the offline cache.
docRef.get(getOptions).then((doc) => {
    // Document was found in the cache. If no cached document exists,
    // an error will be returned to the 'catch' block below.
    console.log("Cached document data:", doc.data());
}).catch((error) => {
    console.log("Error getting cached document:", error);
});

Get multiple documents from a collection (collection.where())

db.collection("cities").where("capital", "==", true)
    .get()
    .then((querySnapshot) => {
        querySnapshot.forEach((doc) => {
            // doc.data() is never undefined for query doc snapshots
            console.log(doc.id, " => ", doc.data());
        });
    })
    .catch((error) => {
        console.log("Error getting documents: ", error);
    });

Retrieve the entire collection (collection.get):

db.collection("users").get().then((querySnapshot) => {
    querySnapshot.forEach((doc) => {
        console.log(`${doc.id} => ${doc.data()}`);
    });
});

Retrieve collection group (db.collectionGroup)

If each document in your cities collection has a subcollection called landmarks, all of the landmarks subcollections belong to the same collection group

var museums = db.collectionGroup('landmarks').where('type', '==', 'museum');
museums.get().then((querySnapshot) => {
    querySnapshot.forEach((doc) => {
        console.log(doc.id, ' => ', doc.data());
    });
});

Receiving realtime updates / snapshots (doc.onSnapshot)

db.collection("cities").doc("SF")
    .onSnapshot((doc) => {
        console.log("Current data: ", doc.data());
});

Latency compensation: When you perform a write, your listeners will be notified with the new data before the data is sent to the backend. Retrieved documents have a metadata.hasPendingWrites property that indicates whether the document has local changes that haven’t been written to the backend yet. You can use this property to determine the source of events received by your snapshot listener:

db.collection("cities").doc("SF")
    .onSnapshot((doc) => {
        var source = doc.metadata.hasPendingWrites ? "Local" : "Server";
        console.log(source, " data: ", doc.data());
    });

By default, listeners are not notified of changes that only affect metadata, but if you really need to, you can enable it:

db.collection("cities").doc("SF")
    .onSnapshot({
        // Listen for document metadata changes
        includeMetadataChanges: true
    }, (doc) => {
        // ...
    });

Stop listening for realtime updates / snapshots

By the way: After an error, the listener will not receive any more events, and there is no need to detach your listener.

var unsubscribe = db.collection("cities")
    .onSnapshot(() => {
      // Respond to data
      // ...
    });

// Later ...

// Stop listening to changes
unsubscribe();

Handle listen errors

db.collection("cities")
    .onSnapshot((snapshot) => {
        // ...
    }, (error) => {
        // ...
    });

Listen to multiple documents in a collection

You can listen to the results of a query. This creates a query snapshot. With this, the snapshot handler will receive a new query snapshot every time the query results change (that is, when a document is added, removed, or modified):

db.collection("cities").where("state", "==", "CA")
    .onSnapshot((querySnapshot) => {
        var cities = [];
        querySnapshot.forEach((doc) => {
            cities.push(doc.data().name);
        });
        console.log("Current cities in CA: ", cities.join(", "));
    });

View changes between snapshots

It is often useful to see the actual changes to query results between query snapshots, instead of simply using the entire query snapshot. For example, you may want to maintain a cache as individual documents are added, removed, and modified.

db.collection("cities").where("state", "==", "CA")
    .onSnapshot((snapshot) => {
        snapshot.docChanges().forEach((change) => {
            if (change.type === "added") {
                console.log("New city: ", change.doc.data());
            }
            if (change.type === "modified") {
                console.log("Modified city: ", change.doc.data());
            }
            if (change.type === "removed") {
                console.log("Removed city: ", change.doc.data());
            }
        });
    });

Perform simple and compound queries

db.collection("cities").where("capital", "==", true)
    .get()
    .then((querySnapshot) => {
        querySnapshot.forEach((doc) => {
            // doc.data() is never undefined for query doc snapshots
            console.log(doc.id, " => ", doc.data());
        });
    })
    .catch((error) => {
        console.log("Error getting documents: ", error);
    });
citiesRef.where("state", "==", "CA");
// combine up to 10 equality (==) clauses with logical OR
citiesRef.where('country', 'in', ['USA', 'Japan']);

citiesRef.where("population", "<", 100000);
citiesRef.where("name", ">=", "San Francisco");

// This query does not return city documents where the capital field does not exist
citiesRef.where("capital", "!=", false);
// combine up to 10 not equal (!=) clauses with logical OR
citiesRef.where('country', 'not-in', ['USA', 'Japan']);

// by array content
citiesRef.where("regions", "array-contains", "west_coast");

// combine up to 10 array-contains clauses
citiesRef.where('regions', 'array-contains-any', ['west_coast', 'east_coast']);

// unlike array-contains-any, the clause matches for an exact match of array length, order, and values
citiesRef.where('region', 'in',  [['west_coast', 'east_coast']]);

Another way to query for document ids using where:

db.collection('books').where(firebase.firestore.FieldPath.documentId(), '==', 'fK3ddutEpD2qQqRMXNW5').get()

Compound queries

Chaining multiple where queries together will join them with logical AND.

citiesRef.where("state", "==", "CO").where("name", "==", "Denver");
citiesRef.where("state", "==", "CA").where("population", "<", 1000000);

// This does not work because you have range filters on multiple fields
citiesRef.where("state", ">=", "CA").where("population", ">", 100000);

Restrictions to make this work:

  • You must create a composite index to combine equality operators with the inequality operators, <, <=, >, and !=
  • You can perform range (<, <=, >, >=) or not equals (!=) comparisons only on a single field
  • you can include at most one array-contains or array-contains-any

Order and limit

orderBy can also be used as a filter. For example, if you order by a field that does not exist on a doc in a collection, then that doc is filtered out from the result.

citiesRef.where("population", ">", 100000).orderBy("population").limit(2);

If what you are ordering is a map, you can even order by a property of the map:

db.collection('books').orderBy('review.john-doe');

Paginate a query (collection.startAfter, startAt)

startAfter is exclusive, meaning it gets all data after the startAfter condition, but not including it, whereas startAt is inclusive. endBefore and endAt also exist.

var first = db.collection("cities")
        .orderBy("population")
        .limit(25);

return first.get().then((documentSnapshots) => {
  // Get the last visible document
  var lastVisible = documentSnapshots.docs[documentSnapshots.docs.length-1];
  console.log("last", lastVisible);

  // Construct a new query starting at this document,
  // get the next 25 cities.
  var next = db.collection("cities")
          .orderBy("population")
          .startAfter(lastVisible)
          .limit(25);
});

Securing data via rules

To secure data access you define rules, either in Firebase Web Console or you define them locally in a database.rules.json that you then deploy.

// Allow read/write access on all documents to any user signed in to the application
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if request.auth != null;
    }
  }
}
// Deny read/write access to all users under any conditions
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}
// Allow read/write access to all users under any conditions
// Warning: **NEVER** use this rule set in production; it allows
// anyone to overwrite your entire database.
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if true;
    }
  }
}

Transactions and Batch Writes

Difference: Transcaction is a set of read and write operations on one or up to 500 documents. Batch Writes is a set of write operations on one or up to 500 documents.

Transactions (db.runTransaction)

  • Read operations must come before write operations. There are no read operations in a batch write
  • A function calling a transaction (transaction function) might run more than once if a concurrent edit affects a document that the transaction reads. That does not apply to batch writes.
  • Transaction functions should not directly modify application state. Instead return the values that change application state
  • Transactions will fail when the client is offline, batch writes do no.
// Create a reference to the SF doc.
var sfDocRef = db.collection("cities").doc("SF");

// Uncomment to initialize the doc.
// sfDocRef.set({ population: 0 });

return db.runTransaction((transaction) => {
    // This code may get re-run multiple times if there are conflicts.
    return transaction.get(sfDocRef).then((sfDoc) => {
        if (!sfDoc.exists) {
            throw "Document does not exist!";
        }

        // Add one person to the city population.
        // Note: this could be done without a transaction
        //       by updating the population using FieldValue.increment()
        var newPopulation = sfDoc.data().population + 1;
        transaction.update(sfDocRef, { population: newPopulation });
    });
}).then(() => {
    console.log("Transaction successfully committed!");
}).catch((error) => {
    console.log("Transaction failed: ", error);
});

Batch Writes (db.batch, batch.commit)

// Get a new write batch
var batch = db.batch();

// Set the value of 'NYC'
var nycRef = db.collection("cities").doc("NYC");
batch.set(nycRef, {name: "New York City"});

// Update the population of 'SF'
var sfRef = db.collection("cities").doc("SF");
batch.update(sfRef, {"population": 1000000});

// Delete the city 'LA'
var laRef = db.collection("cities").doc("LA");
batch.delete(laRef);

// Commit the batch
batch.commit().then(() => {
    // ...
});

Access data offline

With offline persistence enabled, the Cloud Firestore client library automatically manages online and offline data access and synchronizes local data when the device is back online. For the web, offline persistence is disabled by default and is supported only by the Chrome, Safari, and Firefox web browsers. To enable persistence, call the enablePersistence method:

firebase.firestore().enablePersistence()
  .catch((err) => {
      if (err.code == 'failed-precondition') {
          // Multiple tabs open, persistence can only be enabled
          // in one tab at a a time.
          // ...
      } else if (err.code == 'unimplemented') {
          // The current browser does not support all of the
          // features required to enable persistence
          // ...
      }
  });
// Subsequent queries will use persistence, if it was enabled successfully

Cache size

After exceeding the default cache size of 40 MB, Cloud Firestore periodically attempts to clean up older, unused documents. You can configure a different cache size threshold or disable the clean-up process completely:

// The default cache size threshold is 40 MB. Configure "cacheSizeBytes"
// for a different threshold (minimum 1 MB) or set to "CACHE_SIZE_UNLIMITED"
// to disable clean-up.
firebase.firestore().settings({
  cacheSizeBytes: firebase.firestore.CACHE_SIZE_UNLIMITED
});

firebase.firestore().enablePersistence()
  

Listen to offline data

While the device is offline, if you have enabled offline persistence, your listeners will receive listen events when the locally cached data changes. You can listen to documents, collections, and queries. To check whether you’re receiving data from the server or the cache, use the fromCache property on the SnapshotMetadata in your snapshot event. If fromCache is true, the data came from the cache and might be stale or incomplete. If fromCache is false, the data is complete and current with the latest updates on the server.

Disable and enable network access

You can use the method below to disable network access for your Cloud Firestore client. While network access is disabled, all snapshot listeners and document requests retrieve results from the cache. Write operations are queued until network access is re-enabled.

firebase.firestore().disableNetwork()
    .then(() => {
        // Do offline actions
        // ...
    });
firebase.firestore().enableNetwork()
    .then(() => {
        // Do online actions
        // ...
    });

Data modelling

How to store your data is a matter of how you expect your data to be used. Things you have to consider:

  • A doc has a 1 MB limit
  • A doc has a limit of max 40.000 fields
  • You don’t pay for how much data you store, instead you pay for every read or write

Ask yourself: How many items should be in the set:

  • one-to-few then you can consider embedding your data in a document
  • one-to-hundreds, depends on the data but you should tend to create a collection or subcollection
  • one-to-billions then you create a collection or subcollection

Also ask yourself: Is the data public or private?

If the data is private, then put it in a separate collection and apply restricted data security rules to it and

  • either use the same docID in both collections
  • or if you do not want to use the same doc ID, then save the ID of the private doc in a custom field of the public doc

Also ask yourself: Do I need to query data across multiple parents?

If you need to query data often then don’t embed in in a doc, because you would have to read all docs and then do client-side filtering. Instead create a collection or subcollection. For example, of you want to get all books of a specific author, you would put author and books in their own collection and add a author field to the docs in the book collection.

Embedding data in your doc

You embed tags directly in your doc:

"myPost" {
  title: "my title",
  tags: [{name: "my tag A"}, {name: "my tag B"}, {name: "my tag C"}]
}

Very performant and cost effective method. Your data must be small enough not to reach the 1MB per doc limit. You cannot query the embedded docs but instead have to read the entire doc every time you access it.

Root collection

Let’s assume you want to save many tags for one post. Each tag is a document having their own ID (tagIdA, tagIdB etc.) in a root collection. We then reference one or more tagIds within a post using those ids. We have a many-to-many relationship if we store many tagIds in an array, because a post can reference many tags and a tag can be referenced by many posts. To model a one-to-one relationship we only store one tagId in the post.

"myPost" {
  title: "my title",
  tags: [tagIdA, tagIdB, tagIdC] // is an array
  // or use tag: tagIdA for one-to-one relationship
}

"tagsCollection" [
  "tagIdA" {
    name: 'my tag A'
  },
  "tagIdB" {
    name: 'my tag B'
  },
  "tagIdC" {
    name: 'my tag C'
  },
]

A disadvantage may be that you have to make at least two reads to get a small number of data: one for the post and another for its tags.

Subcollections

A subcollection of a doc creates an implicit one-to-many relationship: One post with many tags (as subcollection). The interesting thing is that we only read the tags if we request the tags-subcollection specifically (whereas with Embedding technique we always would get the whole data set).

"myPost" {
  title: "my title",
  tags: [tagIdA, tagIdB, tagIdC] // is a subcollection
}

You can query subcollections by using Subcollection Groups.

Bucketing

Your main data is held in a one collection with a doc having “docID”. Then tags related to that doc are stored in another collection having a doc Id which is also “docID”. In short: posts -> “docID” refers to tags -> “docID”.

tags [
  myFirstPostId {
    tags: [
      tagIdA: {name : 'my tag a'},
      tagIdB: {name : 'my tag b'}
    ]
  }
  myPostBId
]

posts [
  myFirstPostId
]

It requires two reads to get all the tags for one post.

About Author

Mathias Bothe Contact me

I am Mathias, born 38 years ago in Heidelberg, Germany. Today I am living in Munich and Stockholm. I am a passionate IT freelancer with more than 14 years experience in programming, especially in developing web based applications for companies that range from small startups to the big players out there. I am founder of bosy.com, creator of the security service platform BosyProtect© and initiator of several other software projects.