How To Create A Mobile App In Expo And Firebase (For iOS And Android)

About The Author

Chafik Gharbi is an engineer and passionate mobile and web apps developer interested in everything related to JavaScript and cross-platform solutions. More about Chafik ↬

Email Newsletter

Weekly tips on front-end & UX.
Trusted by 200,000+ folks.

In this article, based on my experience with creating a GPS and navigation application, I will show you how to create a mobile app using Expo and Firebase services such as Firestore, Firebase functions and Expo push notifications.

Maybe you’ve heard of or worked with React, the JavaScript framework developed by Facebook. The social media company took it even further by releasing React Native, which quickly became the most popular framework for building mobile apps with JavaScript. Many companies embraced the idea and started building their apps with it.

In this article, we’ll get an idea of how to develop an application for Android and iOS using Expo and Firebase, based on my own experience of creating an application with these technologies. If you haven’t worked with Firebase before, please look at its guide to JavaScript projects before we dive in.

If you are new to JavaScript, make sure you’re clear on the basics of ECMAScript 6’s features, such as class importing and arrow functions. You can learn React Native from the official documentation, which has a section on React fundamentals, in case you haven’t worked with React. Don’t worry about how to build an app with Android Studio or Xcode, because we will be using the Expo framework.

Brief Description of Project

We can describe our project as an on-demand transporter — you could say Uber for merchandise transportation. The user will choose transportation information, such as the type of vehicle and loading and unloading locations, and then nearby transportation vehicles will appear on the map. The user confirms their request, and the drivers receive notifications one by one. Each driver’s notification is active for 25 seconds. If they ignore or decline the request, the system selects another driver, and so on. When a driver accepts the request, the user can monitor the entire transportation process on the map, including via the web application.

Expo Installation And Configuration

First, we need to install the command line interface (CLI) for Expo, which will help us test to the app in a simulator or on real devices and to build our app in the cloud.

npm install -g expo-cli

Let’s create our Expo project.

expo init

The cool part is that all of your app’s configurations can be done in a single JSON file, app.json. Below are some tips I learned that could increase your chances of being accepted in the App Store and Google Play and to help you avoid some common problems.

  • If you are using Google Maps in your app, be sure to provide the API in the app.json configuration file, in order to make it work properly. Google won’t charge you for native map rendering unless you’re rendering directions or using other paid API services.
    ...
    "ios": {
        ...
        "config": {
            "googleMapsApiKey": "YOUR_API_KEY"
        }
    },
    "android": {
        ...
        "config": {
           "googleMaps": {
              "apiKey": "YOUR_API_KEY"
           }
        }
    }
  • To make location updates, or any other background tasks, work in the background in iOS, add the following keys under ios.infoPlist:
    ...
    "ios": {
        ...
        "infoPlist": {
            ...
            "UIBackgroundModes": [
              "location",
              "fetch"
            ]
        }
    }
  • If you don’t define which permissions your app will use, then Expo’s generated app will use all available authorizations by default. As a result, Google Play will reject your app. So, specify your required permissions.
    ...
    "android": {
        ...
        "permissions": [...],
     }
  • Apple requires you to provide a message that tells the user why the app is requesting this access, or else you will be rejected.
    ...
    "ios": {
        ...
        "infoPlist": {
            ...
            "NSCameraUsageDescription": "Why are you requesting access to      the device’s camera?",
            "NSLocationWhenInUseUsageDescription": "Why are you requesting access to the device’s camera?"
          }
    }
  • Make sure to increment the android.versionCode key before you publish a new version to Google Play.
  • All updates can be done with Expo over the air, without passing by Google Play or the App Store, unless you make the following changes:
    • upgrade the Expo SDK version;
    • change anything under the ios, android, or notification keys;
    • change the app’s splash;
    • change the app’s icon;
    • change the app’s name;
    • change the app’s owner;
    • change the app’s scheme;
    • change the facebookScheme;
    • change your bundled assets under assetBundlePatterns.
  • I prefer not to interpret the user experience by setting fallbackToCacheTimeout to 0 under the updates key. This will allow your app to start immediately with a cached bundle, while downloading a newer one in the background for future use.

And here is a complete example of the configuration in app.json:

{
  "expo": {
    "name": "Transportili",
    "slug": "transportili",
    "scheme": "transportili",
    "privacy": "public",
    "sdkVersion": "36.0.0",
    "notification": {
      "icon": "./assets/notification-icon.png",
      "androidMode": "default"
    },
    "platforms": [
      "ios",
      "android",
      "web"
    ],
    "version": "0.3.2",
    "orientation": "portrait",
    "icon": "./assets/icon.png",
    "splash": {
      "image": "./assets/splash.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },
    "updates": {
      "fallbackToCacheTimeout": 0
    },
    "assetBundlePatterns": [
      "\**/\*"
    ],
    "ios": {
      "bundleIdentifier": "com.transportili.driver",
      "supportsTablet": false,
      "infoPlist": {
        "UIBackgroundModes": [
          "location",
          "fetch"
        ],
        "LSApplicationQueriesSchemes": [
          "transportili"
        ],
        "NSCameraUsageDescription": "L’application utilise l’appareil photo pour prendre une photo ou numériser vos documents.",
        "NSLocationWhenInUseUsageDescription": "L’application utilise votre position pour aider les chauffeurs ou les transporteurs à vous trouver sur la carte."
      },
      "config": {
        "googleMapsApiKey": "AIzaSyA8Wcik6dTuxBKolLSm5ONBvXNz8Z0T-6c"
      }
    },
    "android": {
      "googleServicesFile": "./google-services.json",
      "package": "com.transportili.driver",
      "versionCode": 6,
      "permissions": [
        "ACCESS_COARSE_LOCATION",
        "ACCESS_FINE_LOCATION"
      ],
      "config": {
        "googleMaps": {
          "apiKey": "AIzaSyA8Wcik6dTuxBKolLSm5ONBvXNz8Z0T-6c"
        }
      }
    },
    "description": "",
    "githubUrl": "https://github.com/chafikgharbi/transportili-native.git"
  }
}

Let’s move on to installing Firebase, using the following command:

expo install firebase

I prefer to create a firebase.js file in the app’s root folder that contains all Firebase configurations. In this case, I’m using only the Firestore and Storage services.

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",
    measurementId: "G-measurement-id"
};

Now, whenever we want to use Firebase, we just import this file, as follows:

import { firebase, firestore, storage } from "./firebase";

The documentation has a more detailed explanation of using Firebase with Expo.

The Application’s Database

You can store your data directly in the cloud using Firebase, which offers two types of databases. One is the real-time database, and the other is Firestore, which is considered to be the improved version of the real-time database, with more advanced functionality. Both are NoSQL databases with data sync and instant changes listeners. They have different mechanisms: The real-time database stores data as a JSON object, whereas Firestore stores data as documents in collections. They also calculate usage and cost differently: The former is based on the quantity of data exchanged, and the latter is based on the number of operations in the documents (reads, writes, and deletes).

In my case, I used the Firestore database to store users, requests, vehicles, and other application data. (I was trying to be smart by putting all of my data in one document to decrease operation usage, but then I discovered that each document can store only 1 MB.)

In addition to storing strings, numbers, objects, and so on in Firebase, you can also store a geoPoint, which is an object that contains the coordinates of geographic points (latitude and longitude). Despite this, unfortunately, you cannot make geographic queries, such as retrieving nearby users.

To do that, we can use GeoFirestore. But we have to take into account that this package restricts the document structure of the user to this:

User: {
d: {all user data here}
g: (location geohash)
l: {firstore location geopoint}
}

So, if you’re going to implement it directly in your user collection, like I did, then you’ll need to put all of the user’s data in the d key.

Last but not least, don’t forget to optimize your code to avoid unexpected operations:

  • Use offline persistence. On the web, offline persistence is disabled; be sure to enable it.
  • Use cursor pagination in Firestore queries. Don’t get all data at once.
  • Always unsubscribe listeners, when done, or unmounted components.

The Application’s Back End

You can manage the Firestore database, send notifications with Expo, and perform certain operations directly from the front end or the mobile application, but there are other operations that we cannot do without a back end and a server. This is why Firebase offers functions — a cloud back end that allows you to execute Node.js code on a scalable server. I’ve used the Firebase functions for the following:

  • Send notifications (see example below)
    To send notifications, we will use push notifications, a tool that helps an app’s owner send messages to their users. It appear in the notifications section of the device, even if the application is not active. We don’t want this process to be stopped by a sudden interruption in connectivity, so we’ll have to use a server.
  • Run cron jobs
    Using cron jobs helps me to manage scheduled requests and notifications.
  • Sanitize the database
    This includes removing useless and ignored requests.
  • Run sensitive, expensive, or continuous tasks
    This includes registering, retrieving users, and scheduling orders. All of these are sensitive operations. If you make them directly from your app or front end, there is a risk of security vulnerability and broken tasks.

Joaquin Cid’s article “How to Build a Role-based API With Firebase Authentication” will give you details on how to get started with Firebase functions and how to create a back-end API using Express. It uses TypeScript, but converting TypeScript to JavaScript is not hard.

Push Notifications

Expo sends a notification to the user’s device from its servers. It identifies the user’s device with a token. When someone uses the application, the app would execute code to obtain the device’s token, and then store this token on the server. I’ve used Firestore as usual to store the token and compare incoming tokens to check whether the user has logged in from another device.

Storing Expo push token
Data to be stored for subsequent push-notification requests. (Large preview)

We get our token using the following function:

token = await Notifications.getExpoPushTokenAsync();

Don’t forget to request permission to push notifications. The documentation has example usage.

Whenever you want to send a notification to this user, you would make a request to Expo’s server, which contains the user’s device token already stored on your server.

curl -H "Content-Type: application/json" -X POST "https://exp.host/--/api/v2/push/send" -d '{ "to": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]", "title":"hello", "body": "world" }'

The following is a simple example that sends notifications to all users using Firebase functions. This example is not secure. If you want to implement authorization and authentication, please follow Cid’s article mentioned above.

After initializing our project using the Firebase CLI, let’s install the Express framework to handle our API.

npm install express

We need to support CORS and add JSON body-parser middleware. This way, we can make requests from any URL and parse JSON-formatted requests.

npm install --save cors body-parser
npm install --save-dev @types/cors

This is the main index.js file of our functions directory:

const express = require("express");
const cors = require("cors");
const bodyParser = require("body-parser");
const admin = require("firebase-admin");
const functions = require("firebase-functions");

// Initialize the firebase-admin SDK module
admin.initializeApp(functions.config().firebase);

// Set the Express app
const app = express();
app.use(bodyParser.json());
app.use(cors({ origin: true }));

// Handle push notifications request
app.post("/pushNotifications", require("./controllers/pushNotifications"));

// Handle another request
// app.post("/anotherRoute", require("./controllers/anotherController"));

// Export the https endpoint API handled by the Express app
export const api = functions.https.onRequest(app);

And this is the pushNotifications.js controller, located in the controllers folder.

const admin = require("firebase-admin");
const axios = require("axios");
const chunkArray = require("./chunkArray");
const firestore = admin.firestore();

async function pushNotifications(req, res) {
  try {
    const data = req.body;

    // Get users from Firestore, then build notifications array
    await firestore
      .collection("users").get()
      .then((querySnapshot) => {
        if (querySnapshot.size) {

          // This array will contain each user’s notification
          let notificationsArray = [];

          querySnapshot.forEach((doc) => {
            let docData = doc.data();
            if (docData && docData.d) {
              let userData = docData.d;

              // The pushNotificationsToken retrieved from the app and stored in Firestore
              if (userData.pushNotificationsToken) {
                notificationsArray.push({
                  to: userData.pushNotificationsToken,
                  ...data,
                });
              }
            }
          });

          // Send notifications to 100 users at a time (the maximum number that one Expo push request supports)
          let notificationsChunks = chunkArray(notificationsArray, 100);
          notificationsChunks.map((chunk) => {
            axios({
              method: "post",
              url: "https://exp.host/--/api/v2/push/send",
              data: chunk,
              headers: {
                "Content-Type": "application/json",
              },
            });
          });
          return res.status(200).send({ message: "Notifications sent!" });
        } else {
          return res.status(404).send({ message: "No users found" });
        }
      })
      .catch((error) => {
        return res
          .status(500)
          .send({ message: `${error.code} - ${error.message}` });
      });
  } catch (error) {
    return res
      .status(500)
      .send({ message: `${error.code} - ${error.message}` });
  }
}

module.exports = pushNotifications;

In the controller above, we got all of the app’s users from Firestore. Each user has a push token. We divided this list into sets of 100 users, because a single request to Expo can hold only 100 notifications. Then, we sent these notifications using Axios.

The following is the chunkArray function:

function chunkArray(myArray, chunk_size) {
  var index = 0;
  var arrayLength = myArray.length;
  var tempArray = [];

  for (index = 0; index < arrayLength; index += chunk_size) {
    myChunk = myArray.slice(index, index + chunk_size);
    tempArray.push(myChunk);
  }

  return tempArray;
}

This is an example of how to send notifications via our API using Axios.

axios({
  method: "post",
  url: "https://...cloudfunctions.net/api/pushNotifications",
  data: {
    title: "Notification title",
    body: "Notification body",
  },
});

Maps and Geolocation

Render Native Google Maps in React Native

To render Google Maps in the mobile application, I used react-native-maps, and to render directions, I used the react-native-maps-directions package. For a web application, I would use pure JavaScript.

npm install react-native-maps react-native-maps-directions

Then, import these packages:

import MapView, { Marker, PROVIDER_GOOGLE } from "react-native-maps";
import MapViewDirections from "react-native-maps-directions";

We’ll render the map with markers and directions:

<MapView
   style={mapStyle}
   // Reference is useful for controlling the map like mapView.fitToCoordinates(...)
   ref={(ref) => (mapView = ref)}
   // For better performance, avoid using default map on iOS
   provider={PROVIDER_GOOGLE}
   // Show the blue dot that represents the current location on the map
   showsUserLocation={true}
   initialRegion={{
   ...this.state.currentLocation,
   latitudeDelta: LATITUDE_DELTA,
   longitudeDelta: LONGITUDE_DELTA,
   }}
   /*
   * Watch region change when the user moves the map
   * for example, to get the address with reverse geocoding.
   \*/
   onRegionChangeComplete={(region) => {
   console.log(
       `Map center: latitude: ${region.latitude}${region.latitude}
       longitude: ${region.latitude}${region.longitude}`
   );
   }}
   // Map edge paddings
   mapPadding={{
   top: 20,
   right: 20,
   bottom: 20,
   left: 20,
   }}
>
{/* Render marker with custom icon \*/}
   {this.state.marker && (
   <Marker
       title={this.state.marker.title}
       coordinate={{
       latitude: this.state.marker.latitude,
       longitude: this.state.marker.longitude,
       }}
   >
       <MaterialIcons name="place" size={40} color="green" />
   </Marker>
   )}

 {/* Render multiple markers \*/}
   {this.state.markers.map((marker, index) => {
   return (
       <Marker
       key={index}
       title={marker.address}
       coordinate={{
           latitude: marker.latitude,
           longitude: marker.longitude,
       }}
       >
       <MaterialIcons name="place" size={40} color="green" />
       </Marker>
   );
   })}

 {/* Render directions from array of points \*/}
   {this.state.directions.length >= 2 && (
   <MapViewDirections
       origin={this.state.directions[0]}
       destination={
       this.state.directions[this.state.directions.length - 1]
       }
       waypoints={
       this.state.directions.length > 2
           ? this.state.directions.slice(1, -1)
           : null
       }
       optimizeWaypoints={true}
       apikey={GOOGLE_MAPS_APIKEY}
       strokeWidth={5}
       strokeColor="green"
       onReady={(result) => {
       console.log(
           `Distance "${result.distance} km", "${result.duration} min"`
       );
       }}
       onError={(errorMessage) => {
       console.log(errorMessage);
       }}
   />
   )}
</MapView>

Watch User’s Location in Foreground and Background

The Expo framework supports background location updates, I want to use this feature to get the user’s position. Even if the app is not in the foreground or the phone is locked, the application should always send the location to the server.

import * as Location from "expo-location";
import * as TaskManager from "expo-task-manager";
import geohash from "ngeohash";
import { firebase, firestore } from "../firebase";


let USER_ID = null;
let LOCATION_TASK = "background-location";

let updateLocation = (location) => {
  if (USER_ID) {
    firestore
      .collection("users")
      .doc(USER_ID)
      .update({
        "d.location": new firebase.firestore.GeoPoint(
          location.latitude,
          location.longitude
        ),
        g: geohash.encode(location.latitude, location.longitude, 10),
        l: new firebase.firestore.GeoPoint(
          location.latitude,
          location.longitude
        ),
      });
  }
};

TaskManager.defineTask(LOCATION_TASK, ({ data, error }) => {
  if (error) {
    // Error occurred - check `error.message` for more details.
    return;
  }
  if (data) {
    const { locations } = data;

    // Current position with latitude and longitude
    currentLocation = {
      latitude: locations[0].coords.latitude,
      longitude: locations[0].coords.longitude,
    };
    updateLocation(currentLocation);
  }
});

export default async function watchPosition(userid) {
  // Set user ID
  USER_ID = userid;

  // Ask permissions for using GPS
  const { status } = await Location.requestPermissionsAsync();
  if (status === "granted") {
    // watch position in background
    await Location.startLocationUpdatesAsync(LOCATION_TASK, {
      accuracy: Location.Accuracy.BestForNavigation,
      distanceInterval: 10,
      showsBackgroundLocationIndicator: true,
      foregroundService: {
        notificationTitle: "Title",
        notificationBody: "Explanation",
        notificationColor: "#FF650D",
      },
    });
    // Watch position in foreground
    await Location.watchPositionAsync(
      {
        accuracy: Location.Accuracy.BestForNavigation,
        distanceInterval: 10,
      },
      (location) => {
        let currentLocation = {
          latitude: location.coords.latitude,
          longitude: location.coords.longitude,
        };
        updateLocation(currentLocation);
      }
    );
  } else {
    // Location permission denied
  }
}

If you’ll notice, I’ve used different structures when updating the location to Firestore. That’s because I’m using the GeoFirestore package to query nearby users.

Using WebView in React Native

The application is not only for mobile users, but also for desktop users. So, let’s not spend time developing another application that shares much of the same functionality, such as login and registration, profiles and settings, and orders history.

On the app website, we check whether the user came from a desktop browser or the mobile application. We then redirect them to the corresponding application.

For a mobile application, we have to implement some sort of communication between the native app and WebView app, thanks to the JavaScript injection of postMessage and onMessage in WebView. But be careful when and how you use it:

Security Warning: Currently, onMessage and postMessage do not allow specifying an origin. This can lead to cross-site scripting attacks if an unexpected document is loaded within a WebView instance. Please refer to the MDN documentation for Window.postMessage() for more details on the security implications of this.

React Native documentation

We’ll send data from web JavaScript to React Native. Here is an example of sending a user ID:

window.ReactNativeWebView.postMessage(
    JSON.stringify({
        action: "setUserID",
        data: user.uid
    })
);

We’ll listen to data coming from the web in WebView.

<WebView
  ref={(reference) => (webview = reference)}
  onMessage={(event) => {
    let message = JSON.parse(event.nativeEvent.data);
    switch (message.action) {
      case "setUserID":
        let id = message.data;
        break;
      case "anotherAction":
        //
        break;
    }
  }}
/>;

Let’s send data from React Native to the web. The following example sends a location retrieved from React Native.

let location = JSON.stringify({ latitude: 36.742022, longitude: 3.103771 });
webview.injectJavaScript(`
  window.injectData({
    action: "setLocation",
    data: JSON.stringify(${location})
  })
\`);

We’ll read the location on the web:

window.injectData = (message) => {
  switch (message.action) {
    case "setLocation":
      let location = JSON.parse(message.data);
      break;
    case "anotherAction":
      //
      break;
  }
};

The Web Application and Website

All web-related parts, from the website to the web application, were made with Next.js and hosted on Netlify for three main raisons:

  • cost-effectiveness
    There is no server to maintain, and Netlify’s free plan is more than enough for my needs. Unlimited private repositories are now free on GitHub, so nothing to worry about there.
  • effortless development
    Commit, push, and let Netlify do the rest. Is anything simpler than that?
  • speed
    The websites are static and all hosted on a content delivery network (CDN). When a user requests these websites, the CDN directs them to the nearest copy in order to minimize latency. So, the websites are extremely fast.

Limitations of Expo

There are two approaches to building an app with Expo: the managed workflow, where you write only JavaScript, and Expo tools and services do the rest for you, and the bare workflow, where you have full control over all aspects of the native project, and where Expo tools can’t help as much. If you plan to follow the first approach, then consider Expo’s limitations, because some functionality that exists in major apps, such as Spotify (for example, music playing in the background) and Messenger (call notifications), cannot be done yet.

Conclusion

Expo is an excellent choice if you are not familiar with native development and you want to avoid all of the headaches associated with creating and regularly deploying an application. Firebase can save you a lot of time and work, because of its scalability and variety of services. However, both are third-party services, over which you have no control, and Firestore is not designed for complex queries and data relationships.

Thanks for your attention. I hope you’ve enjoyed this article and learned something new.

Smashing Editorial (ra, yk, il, al)