Overview
Google's Firebase is a suite of services that make it possible to build multi-user data-rich web apps without creating a web server of your own. The level of service for free is relatively generous and more than adequate for most prototype projects.
Northwestern Google accounts do not support Firebase. To create a Firebase project, you need to use a personal Google account, or create one if necessary.
The Firebase CLI
The Firebase command line interface is the best way to set up your app to talk to Firebase. See these instructions on how to install the Firebase CLI on your local machine. Install the npm CLI rather than the standalone binaries, so that every team member is working with the same interface, whether Window, MacOS, or Linux.
On your local machine, test your installation with
firebase login firebase projects:list
If your machine is not already logged onto Firebase, you will be asked to provide your username and password. Then the list command will show what projects you have, if any.
If Firebase says you are logged in, but gives an error trying to list project, try re-authorizing with
firebase login --reauth
The most common Firebase CLI commands you will use are
- firebase init to set up and configure different Firebase services for your app.
- firebase deploy to upload your app to the Firebase web host, after you do npm run build.
- firebase login if Firebase says you are not logged in.
Creating a project on Firebase
A "project" on Firebase is a set of resources and services that can be used by one or more apps. You create a Firebase project with your browser on the Firebase web console. Follow these instructions.
For prototyping, the most common services to set up are web hosting, authentication, Firestore and/or the Realtime database, and if you need to store images, Firebase Storage (not to be confused with Firestore).
To set up a Database, cick on the Database link on the left. Firebase will offer two options: FireStore and Realtime Database.
Firebase will ask if you want the Database in test mode or locked / production mode. Pick test. It's not secure but it will let you start writing code to read and write data without writing security rules. Firebase will send you emails about this until you fix those rules later.
If this is for a team project, be sure to add your team members to the project. See these instructions.
Adding Firebase to a React app
In a command shell, switch into the directory for your app. Then do
firebase init
This will ask you a series of questions. These change every few months so read carefully. Google adds several new features to Firebase every year. For most CS 394 projects, you will just want the Realtime Database and web hosting. Say yes to the following, and no to everything else
- Database for the Realtime Datasbase
- Hosting so you can deploy your web app onto the Firebase server
- Storage if you will need to store images
Do not select the following unless you really know what you are doing:
- Firestore -- this is database that collects data into documents like MongoDB
- Functions -- this provides serverless functions that run on the Firebase server; rarely needed in CS 394 projects
- Emulators -- this is a local version of the Firebase; we may install these later in the class
- App Hosting -- this is for hosting server-side multi-page apps built with frameworks such as Next.js or Angular
- Github integration -- this is for deploying your app to a preview server when you push to Github; we will see how to do this when we cover continuous integration with Github Actions
You can always run firebase init again later to add services.
Firebase will ask what Firebase project to connect to? Pick the one you created.
If you forget to say dist for the public directory, Firebase will not know where your React app is. You can fix this mistake by editing the file firebase.json. Look for the key public in the file and change the value from "public" to "dist". Save, build (with npm) and deploy.
Next you need to install the Firebase code library for your app.
npm install firebase
Now any code file that needs to call Firebase functions, such as firebase.js, can import that code with
import firebase from 'firebase/app'; import 'firebase/database';
Your code needs to initialize Firebase on startup, with something like this:
const firebaseConfig = {
apiKey: "api-key",
authDomain: "project-id.firebaseapp.com",
databaseURL: "https://project-id.firebaseio.com",
projectId: "project-id",
storageBucket: "project-id.appspot.com",
messagingSenderId: "sender-id",
appID: "app-id",
};
firebase.initializeApp(firebaseConfig);
The config data can be retrieved at any time from the Firebase console. See these instructions.
None of the configuration data needs to be kept secret. It's OK for this code to be in a file stored on Github.
Initializng test data
You may have data you want to initialize your database to, for testing or other purposes. With the Realtime database, you just make a legal JSON file with the data, and use the Firebase console to import it into the database.
If you want to initialize a Firestore database to test data, you write code to read the data and call the appropriate commands to store data, like this NodeJS example from Google.
Debugging Firebase problems
Deployment problems
There are a few common problems that arise when trying to deploy to Firebase hosting. Often there are no error messages, just a failure for changed code to appear on the Firebase site.
Public vs build
By default, the deploy command uploads the directory public to Firebase. But that's not where React tools put production code. If you failed to specify the correct directory when running firebase init, deploy will upload the wrong code.
Open the file firebase.json in your editor. If it says
"hosting": {
"public": "public",
...
change it to "public": "dist". This is where Vite puts
production code.
Re-build before re-deploy
The local server managed by npm run start is updated every time a file is changed, but the dist directory is updated only when you run npm run build. So you must remember to do that before calling firebase deploy.
Realtime Database pitfalls
The Realtime Database is conceptually very simple, but it is not like SQL databases in a number of ways
- Data is in a single big JSON object, not a set of tables and rows. This is simple to say, but tricky to implement properly.
- You don't query for data. You subscribe to changes in data.
- Do not use the Realtime Database for binary objects such as images. For that, use Cloud Storage.
- By default, Firebase creates the database in locked mode. That means that no program can read or modify your data. For initial testing, you need to change it to test mode, where all reading and writing is allowed.
- With test mode, anyone can read or modify your data if they have the URL. You must define security rules to prevent this. Firebase will email reminders every week until you fix this.
- There is no error checking when storing data. A simple bug can complete erase all your data. You must write validation rules to prevent bad data from being entered.
Unintended functions directory
Check for a subdirectory called functions in your app directory. This is created if you selected the Functions feature when you ran firebase init. If you have not actually defined any Firebase Cloud functions, this directory will cause errors about a missing node_modules when you deploy. Delete the directory.
Conflicting lock files
Check for yarn.lock file in your app directory. If you have both package-lock.json and yarn.lock in your app directory, Node-based tools, like the Firebase CLI, will get confused. If you are using npm, delete yarn.lock. Use just npm or yarn, not both.
Log Firebase calls
Errors in code are always annoying, but errors involving database calls can be lead to major slowdowns, lost data, or unexpected monetary costs. Imagine an endless loop that writes data. Imagine fetching the same data every time a web page is re-rendered.
For that reason, I recommend that you
- Refactor all code that reads or writes data into one file with a few core functions.
- Add logging code (to the console or a file) and basic error checking code to those functions.
Only remove or turn off the logging code when your app database interactions haven't needed any changes for weeks.
Reading Firebase data
After Firebase has been initialized, you can fetch your data as one big JSON object with this:
get(ref(database, '/'), snap => {
if (snap.val()) {
...do something with the JSON in snap.val()...
}
}
);
This is not great code.
- It doesn't report errors if they occur. It just silently and frustatingly fails. When you first work on Firebase, you will make many mistakes.
- It downloads all the data, even if you just need a small subportion of the data.
- It won't tell you when the data at Firebase changes. Many apps these days are multi-user, so the data can change at any time.
Suppose our data is a JSON object for a baseball app, that has a list of teams, a list of players, and a list of games that includes what teams were playing and what the scores were. Then an app that wants to show current scores should do something like this:
onValue(ref(database, '/games'), snap => {
if (snap.val()) {
...do something with the games in snap.val()...
}
}, error => {
...do something with the error message...
}
);
This call to onValue() will download the games data and call the function. snap.val() will be the data, or null if there is no data.
More importantly, the function will be called again, any time any data under the /games path changes. And if there's a problem getting the data, such as a permissions issue the second function will be called, with an error message.
Writing Firebase data
There are four ways to save data to Firebase.
Setting data
The simplest is set(). Follow a path to where you want to store the data, then call set() with the data you want to store. E.g., to store data about a user with the ID userId:
set(ref(database, `users${userId}`), {
name: 'Mary Smith',
email: 'msmith@example.com'
});
Firebase will add the key userId if it's not already in the JSON, and then store the object under it.
Updating fields
If you want to just update one or more fields in some data without affecting any other fields, use update(). You pass it an object with keys and values to store at the given location. E.g., to change a user's email address, but leave any other data about the user alone:
update(ref(database, `users${userId}`), {
email: 'mary.smith'@example.com'
});
You can update multiple keys in one update. For example, for a chat-type application, it's best to store all posts under a top-level key, but also store posts for each user stored under the user. Code for adding a new post would look like this:
// uid is the user ID
// postData is an object with the post's content, timestamp, title, etc.
// Get an ID for the post
const newPostKey = push(child(ref(db), 'posts')).key;
update(ref(db), {
[`/posts/${newPostKey}`]: postData,
[`/user-posts/${uid }/${newPostKey}`]: postData
});Adding to a list
If you want to add a new object to a list, the first thing to be aware of is that lists are not first-class citizens in Firebase. Anything you store that you want to retrieve should have an unchanging path from the root of the database. Being number 4 in a list is not a stable location.
For adding users, there is a unique user ID. But items in many lists don't have an ID. You can tell Firebase to add an object to a list and create an ID for us with push(). For example, to add a game to our list of games:
const gameRef = push(ref(database, 'games'), {
date: '5/9/2019'
teams: ['Cincinnati Reds', 'Oakland Athletics'],
score: [3, 0]
});
push() with a parent and value returns a reference to the newly added object. Notice that it is OK to use arrays, as this code does, as long as the data will be retrieved as a whole unit.
Handling concurrent multi-user updates
set() and update() are dangerous if multiple users might be updating the same data. push() does not have this problem. If two users add a game at exactly the same time, one will get pushed first, then the other.
But suppose you want to let users "like" a team. We might try to increment it with this code:
update(ref(database, `teams${team.id}`), {
likes: team.likes + 1
});
This is wrong! If two users try to like at the same time, both may get the same stored value, e.g., 100. Then the code above would be called by each user, setting the new value to 101 instead the correct 102.
To handle this, use runTransaction() instead of set(). You pass transaction() a function. That function will be called the current data at the location you are trying to update. Your function should return the new value to store. Firebase will compare the value currently in the database with the value your function was given. If they are same, your function's return value is stored. If they are different, Firebase will call your function again with the current value. This ensure that your update function always has the most current value, no matter what order concurrent updates happen in.
So, to properly update our "likes" counter for a team, we should write this:
runTransaction(ref(database, `teams${team.id}`), likes =>
(likes || 0) + 1
);
The OR expression (likes || 0) is used to return 0 if
the likes value happens to be null.
To cancel a transaction, return undefined, i.e., call return with no arguments.
This example of counting likes lets users like things as many times as they want. This Firebase example shows how to avoid that, if you have the ID of the authenticated user.
Updating React state and persistent data
Pay attention: people get this wrong all the time, leading to apps that display out of date information.
Most of the time, when you save data to a database, you will also need to update what your app shows. For example, if someone posts a comment, you want the poster and everyone else to see the comment. If a player gets a new high score, you want everyone to see it, including the player.
With Firebase Realtime Database or Firestore, the best way to do this is:
- Use set() or update() to save the new data to Firebase. Do not set local state or try to modify what's displayed.
- Use onValue() to update your local state when the database changes.
- When the local state is changed, React will automatically re-render the component with the new state.
This way, your app will always display the most current data from Firebase, no matter which user has changed it. The local state will always be in sync with the database. This is the whole point of using a realtime database like Firebase.
Debugging Firebase locally
You can run a local Firebase database to test your code without touching the real database your deployed application is using.
This is new technology, likely to change in parts. See the documentation for current details.
Install the Firebase emulators
In a command shell, in your project directory, execute
firebase init emulators
This presents a series of menu choices, similar to firebase init.
The only emulator you need for now is the realtime database.
Accept the default answers for the other questions asked.
To run the emulator:
firebase emulators:start
This will start all installed emulators.
To see the database, open the URL printed when the emulators started. This is typically http://localhost:4000/database.
You should see an empty database.
An error that the database port is already taken usually means a prior run of the emulator did not halt completely. To clear it, you need to kill the emulator Java process. Use the Linux command lsof to get the ID of the emulator process that is listening on port 9000:
lsof -nP -iTCP:9000 | grep LISTEN java 57199 riesbeck 22u IPv6 0xe8f25e7ccff719d 0t0 TCP 127.0.0.1:9000 (LISTEN)Then use the Linux command kill to kill that process:
kill -9 57199
Set up testing data
The local database will be created fresh every time you run the emulator. To have it start with sample data for testing, first create the data in your emulator. You could do this manually with the browser interface, but a better option is to create a JSON file, and then import it with the browser interface.
When the database has what you want, with the emulators still running, open a new command shell in your project, and execute
firebase emulators:export ./src/saved-data
This will store the data, and any security and validation rules you have created, in the directory src/saved-data. You can use any directory you want. You could create multiple data directories, with different test sets.
Now stop the emulator. Start it with
firebase emulators:start --import=./src/saved-data
Use the browser to inspect the database. It should have the data you exported.
To make future emulation easy, add this script to your package.json
"scripts": {
"start": "vite",
"build": "vite build",
"serve": "vite preview",
"test": "vitest --ui",
"coverage": "vitest run --coverage",
"em:exec": "firebase emulators:exec --ui --import=./saved-data 'npm start'"
},
Then you can start the emulators with
npm run em:exec
Configure your app for local data
The final step is to make your application use the local database when you are in development mode.
In React, you test using the local Webpack server. You can detect this in your JavaScript code by seeing if the hostname in the current URL is localhost. Use that to select the appropriate database URL, like this:
const dbURL = window.location.hostname === 'localhost'
? 'http://localhost:9000?ns=YOUR_PROJECT_ID'
: 'https://YOUR_PROJECT_ID.firebaseio.com';
const firebaseConfig = {
...
databaseURL: dbURL,
projectId: "YOUR_PROJECT_ID",
...
};
firebase.initializeApp(firebaseConfig);
In Expo / React Native, the variable __DEV__ is set to true in development mode. Use that to select the appropriate database URL, like this:
const dbURL = __DEV__
? 'http://localhost:9000?ns=YOUR_PROJECT_ID'
: 'https://YOUR_PROJECT_ID.firebaseio.com';
const firebaseConfig = {
...
databaseURL: dbURL,
projectId: "YOUR_PROJECT_ID",
...
};
firebase.initializeApp(firebaseConfig);
Test
If you've done the above, then when you are developing code, you can run your application with the emulated database with these commands:
npm run emulate npm start
Note: your application will hang until you start the emulators.
Firebase Data Design
JSON is not the same as a JavaScript object
The rules for JSON are stricter. The following is a legal JavaScript object, but not legal JSON:
{ id: "jsmith", email: "john.smith@example.com" }
In JSON, keys must strings, so you need to write
{ "id": "jsmith", "email": "john.smith@example.com" }
When writing JSON by hand, use a JSON validator to avoid annoying Firebase errors.
When #saving data to Firebase, Firebase will convert your JavaScript object to legal JSON. But if you are importing a JSON file to initialize a database, it needs to follow the rules above.
Arrays are not first-class citizens in Firebase
This may seem unintuitive. Arrays are first-class in JSON. And isn't a database at heart an array of objects? In Firebase, the answer is no.
A Firebase database is key-value pairs, where values are strings, numbers, and nested key-value pair objects. Keys play a prominent role. For example, if you had a list of users, with ids and emails, a common JSON representation would be
{
"users": [{
"id": "jsmith",
"email": "john.smith@example.com"
}, {
"id": "mjones",
"email": "mary.jones@example.com"
}]
}
This is not good Firebase data design. Instead of an array of objects, use an object with appropriate keys for each value, like this:
{
"users": {
"jsmith": { "email": "john.smith@example.com" },
"mjones": { "email": "mary.jones@example.com" }
}
}
Another example might be if you have a list of messages. Don't make an array of them. Make an object whose keys are the message ID or message timestamp.
You can have an array of primitives, e.g., a list of numbers or a list of email addresses, but think about whether these really should have more contentful keys than just "0", "1", and so on.
When working with JSON structured like the above, the JavaScript methods Object.keys(), Object.values(), and Object.entries() are incredibly helpful. For example, if the variable json contains the flattened user data example above, then this JSX expression would calculate an array of "mailto" links, suitable for inserting into a web page:
Object.entries(json.users).map(([id, user]) => (
<a href={`mailto:${user.email}`}>{id}</a>
));
Keep data flat
When designing your Firebase data structure, keep it as flat as possible. Avoid deep nesting of objects. For example, if your application has groups of users, you might be tempted to store users like this:
{
groups: {
group-id1: {
name: "Team Aqua",
members: {
user-id-1: {
name: "John Smith",
email: "jsmith@example.com",
...
},
user-id-2: {
...
}
...
}
}
group-id-2: {
...
}
}
}This requires retrieving all the member data even if all we want is just a list of group names. Firebase recommends more top-level keys and less nesting, like this:
{
groups: {
group-id1: {
name: "Team Aqua",
members: {
user-id-1: true,
user-id-2: true,
...
}
}
group-id-2: {
...
}
}
users: {
user-id-1: {
name: "John Smith",
email: "jsmith@example.com",
...
},
user-id-2: {
...
}
}
}
The exact design depends on what your application needs. If you frequently show groups and their
member names, then you might use the member names instead of true in the group
objects. If you frequently show what groups a user belongs to, then you might store the group IDs
and group names
in the user objects. This design allows you to retrieve just the groups or just the users without
retrieving the entire dataset. This is a key part of Firebase's performance and scalability.
Compared to a traditional SQL database, data is stored redundantly in multiple places to allow for fast retrieval. Since reads are far more common than updates, this is sensible. See the sample code for update() for how to store the same data in multiple places in one call.
Readings on Firebase data design
- basic data design -- They use the relational term "denormalize" informally to mean "redundantly repeat information to improve performance."
- Firebase Realtime Database Many to Many Relationship Schema -- for those who know relational database, a comparison of how to represent many-to-many relationships in Firebase.
- JSON tree data structure -- an example with users and messages
Security for Production Use
For testing prototypes, you can start Firebase databases in a Test mode that does no security checking. Anyone can read and edit your data. As soon as you start using an app for real, you must define security rules to manage access to your database. Firebase will periodically send an alert for any database that allows open access, and will eventually deactivate the project.
Security rules determine which users can read or write different parts of the database. The format of these rules differs greatly between Firestore and Realtime. The policy is defined on the database. It applies to any web app or mobile client that accesses the database.
Security rules are critical but tricky to get right. genAI can write rules but you need to understand how to specify a security policy and verify that the genAI rules implement that policy. See the Realtime section below for an example of a security policy and how that translates into Realtime security rules. See the Firestore section for what Firestore rules look like.
Firebase Realtime security rules
The Realtime database system uses a very compact JSON-based format for describing access to the database. As a simple example of defining security rules, let's assume a tiny database of teams and players for a sports league app.
{
"teams": {
"blue": { "name": "Blue Devils" },
"green": { "name": "Green Ghosts" },
"red" { "name": "Red Wings" }
},
"members": {
"jsmith": { "email": "john.smith@example.com", "team": "green" },
"mjones": { "email": "mary.jones@example.com", "team": "blue" },
"bbrown": { "email": "bill.brown@example.com", "team": "blue" },
}
}
Short player names are used here for simplicity. In a real application, "jsmith" and so on would normally be something like a Google user ID.
We want to implement the following security policy for any web or mobile app using this database:
- Anyone, logged in or not, can see the data under the path "teams"
- Authenticated users can also see the data under the path "members"
- An authenticated user can edit their own email, e.g., Bill Brown can change the data in "members/bbrown/email" but not in "members/jsmith/email"
- Mary Jones is an admin and can read and write any data
Security rules are based on query paths. A web app reads and writes data in a Realtime database using query paths, e.g., "teams" or "members/bbrown/email". Realtime security rules are specified with a JSON object whose structure mirrors the nesting of the JSON data, but contains security rules rather than data. A security rule is a simple object of the form:
{
"read": "JavaScript expresssion",
"write": JavaScript expression"
}
When a read or write is received, Firebase first applies the query path to the JSON security object. Access is allowed only if an expression is found that returns true.
Here is a JSON object that implements the above policies. This would be stored in database-rules.json in your Firebase project. They can be managed in the Firebase console but keeping them in a file in the project directory means that changes can be documented and tracked like other code.
{
"rules": {
".read": "auth != null && auth.uid === 'mjones'",
".write": "auth != null && auth.uid === 'mjones'",
"teams": {
".read": true
},
"members": {
".read": "auth != null",
"$uid": {
"email": {
".write": "auth != null && auth.uid === $uid"
}
}
}
}
}
Firebase applies the query path for a read or write operation to the JSON security object one element at a time. First, the root of the JSON security object is checked for a rule with a read or write expression. If there is one and it returns true, Firebase grants access. Otherwise, Firebase looks at the subtree indexed by the next element in the query path. If there is no next element, Firebase denies access.
For keys in the query that are user data, like a user name, you use a pattern variable in the JSON security object. A pattern variable starts with a dollar sign, like $uid, and matches any input key. Firebase will set that variable to the key in the query path. See the table of examples below for how this is used.
The tree above has a rule at the root with the same expression for read and write. That expression is true only if there is an authenticated user and it is Mary Jones. The expressions work becaause Firebase sets the variable auth to the Firebase user object for the current logged-in user, if any, or null.
This is not the best way to implement a policy like this. It means you have to edit security rules every time there is a change in administrators, and the rules get complicated if there are many administrators. A better approach is role-based authorization.
Here are some more examples of read and write operations and what the above rules will do.
| Query Path | Operation | User | What happens |
|---|---|---|---|
| teams | read | null | The root rule returns false, so the rule under "teams" is tested for read access. It returns true so access is granted. |
| teams | write | null | The root rule returns false, so the rule under "teams" is tested for write access. It returns false. There is no more to the path, so access is denied. |
| members | read | null | The root rule returns false, so the rule under "members" is tested for read access. It returns false because there is no user. There is no more to the path, so access is denied. |
| members | read | jsmith | The root rule returns false, so the rule under "members" is tested for read access. It returns true because there is a user. Access is granted. |
| members/bbrown/email | write | bbrown |
The root rule returns false, so the rule under "members" is tested for write access.
There is no write expression so it returns false. Firebase matches
the pattern variable $uid to the string bbrown.
The write rule under $uid
returns true because the ID of the authenticated user matches the $uid
in the query path. Access is granted.
|
For more on the kinds of rules you can write, see the Realtime documentation.
Firestore Security Rules
Firestore security rules are usually stored in the file firestore.rules. They can be managed in the Firebase console but keeping them in a file in the project directory means that changes can be documented and tracked like other code.
Here's a simple example that allows any authenticated user to read and write all documents. This is not a secure rule!
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.auth != null;
}
}
}
A somewhat more realistic rule is this one that allows users to manage data stored under their user ID.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId} {
allow read, update, delete: if request.auth != null && request.auth.uid == userId;
allow create: if request.auth != null;
}
}
}
The match /users/{userId} line will set the variable userId to the user ID in the document path. request.auth variable in the condition will hold the Firebase user object for the currently logged in user, if any, or null.
Debugging security rules
There are two ways to test and debug your security rules. For automated testing, you write unit tests in a Node application, with the @firebase/rules-unit-testing module. That defines an object that lets you load database rules into a local emulated database.
You can do manual testing with the Rules Playground under the Rules section of the Firebase database dashbboard. This lets you define rules and test paths before saving the rules. You can test with and without an authenticated user.
Readings on security rules
- basic security concepts
- Christopher Esplin's post on working with security rules
- The Firebase user object
- How Security Rules Work -- compares Firestore and Realtime rules
- Structuring Cloud Firestore Security Rules
- Writing conditions for Cloud Firestore Security Rules
- Install the Firebase Emulators
- Build unit tests -- Firebase documentation on unit testing with emulators
Validation rules
Rules can also be used to validate data before it is stored. This is needed to prevent data corruption. Buggy code can easily corrupt or completely erase data. Once the JSON tree is corrupted, recovery is often impossible. Lost data is lost. There is no undo.
The rules below add some basic validation to avoid
storing empty data in our team database, using
the predefined variable newData. newData holds
the data that is going to be stored at a location.
"rules": {
".read": "auth != null && root.child('admins').hasChild(auth.uid)"
".write": "auth != null && root.child('admins').hasChild(auth.uid)"
"teams": {
".read": true,
"$team_id": {
"name": "newData.isString() && newData.val().length > 0"
}
},
"users": {
".read": "auth != null",
"$user_id": {
".write": "auth !== null && author.uid === $user_id",
"email": "newData.isString() && newData.val().length > 0",
"team": "newData.isString() && root.child('teams').hasChild(newData.val())"
}
}
}
}
These additions make sure that
- a team name must be a non-empty string
- a user's email must be a non-empy string
- the team given for a user must be one of the teams in the database
A Firebase write operation will fail if it any of these tests returns false.