Building a Serverless React App on Firebase Functions


View of the Bernese Alps from Schilthorn. © 2019 John Apostol
View of the Bernese Alps from Schilthorn. © 2019 John Apostol

This is a step-by-step guide to building your own serverless React app with the practicality afforded by Next.js and the Firebase ecosystem.

After reading about Next.js’s new serverless mode and attending ServerlessDays ATX, I got the itch to wire up a simple example for a Firebase-flavored serverless app.

Read before you proceed: Firebase bundles all of your functions together and doesn’t natively support per-function bundling. A sufficiently large app with many routes may run into scaling issues. See this comment.

I wrote this all before I found this out so I’m publishing this anyway. Proceed if you dare!


First, let's take a look at our toolbox.

Next.js

If you’ve ever built a major React app from scratch, you know what config hell it can be. Next.js is an opinionated, fully-featured React framework that gives you sane defaults upfront. Later on, you can customize your .babelrc and webpack config to suit your app’s needs. You can even pre-render your app on the server (or function in our case).

Most important for our purposes, Next.js features a new serverless mode that’s optimized for cold starts and small function size.

Firebase Functions

AKA Cloud Functions for Firebase

Can’t have serverless functions without a serverless hosting provider. There’s servers running our code somewhere but we want them abstracted away.

The Firebase platform is a nice choice, because it can scale with your app’s needs for a data store, authentication, logging (albeit simple), static file hosting, and user file storage. There’s a little bit of a learning curve when integrating with some features but I’ve generally found it simpler to work with when compared to something like AWS.

That said, permissions are currently platform-wide with beta support for per-function IAM. Be wary if you’re keen on following the principle of least privilege.

The rest of this post will explain how to set everything up step-by-step. If you’d rather get going with less hand-holding, see this repo instead:


From scratch

Fire up your text editor and mkdir something fancy, then let’s get started!

Begin by installing or swapping over to Node.js 8. Use the version of npm bundled with that to run these commands.

  1. npm init
  2. npm install next react react-dom

Our app’s main dependency is on the next package, which lists react and react-dom as peer dependencies. Installing just next is not enough.

Specify "engines": { "node": "8" } in your package.json so Firebase Functions knows which runtime to provide you.

One of my secret joys is to use the latest version of Node.js in whatever new project I work on. Hold off on that urge here, because it’s important to note that Firebase Functions only support Node.js 6 or Node.js 8 runtimes as of this writing. Lock it down for now to avoid any headaches later.

Let’s continue by installing some more dependencies.

  1. npm install firebase-admin firebase-functions
  2. npm install firebase-tools --save-dev

firebase-admin and firebase-functions are necessary for your functions to run on Firebase, while firebase-tools is what you'll use to deploy and manage your app.

After setting up those dependencies, let’s create a new Firebase project for our app to live in. Visit https://console.firebase.google.com/ and follow the on-screen steps in order to create a project.

Once you have your project, you can associate your code:

  1. npx firebase login
  2. npx firebase use --add

The first command will launch your browser and prompt to login to the Google account associated with your Firebase project. Use the second command to select the project you just created.

Notice that a .firebaserc file was created in your project directory. This file serves to map local development aliases to Firebase projects. If you create more projects and define more aliases, npx firebase use [alias] can be used later to swap deploy targets. A common scenario is swapping between production and staging environments.

We should have a directory structure that looks like this:

1|-node_modules *new*
2|-.firebaserc *new*
3|-package-lock.json *new*
4`-package.json *new*

OK, now we will need to build a simple Next.js app with routing.

Different routes are served by different functions (at runtime). We can deploy new code on separate routes without breaking our whole Next.js app!

Create a directory structure with blank files like so:

1|-node_modules
2|-src *new*
3| `-app *new*
4| |-components *new*
5| | `-Nav.js *new*
6| |-pages *new*
7| | |-About.js *new*
8| | `-Index.js *new*
9| `-static *new*
10| `-placeholder.json *new*
11|-.firebaserc
12|-package-lock.json
13`-package.json

All upcoming examples will assume this directory structure.

Let’s build out the Nav component:

1// src/app/components/Nav.js
2
3// Next.js has a nice router we'll use
4import Link from 'next/link'
5
6// The links are based on the URLs that will serve those pages
7export default () =>
8 <ul>
9 <li>
10 <Link href='/'><a>Home</a></Link>
11 </li>
12 <li>
13 <Link href='/about'><a>About</a></Link>
14 </li>
15 </ul>

Now wire up those two pages, Index and About:

1// src/app/pages/Index.js
2
3import Nav from '../components/Nav.js'
4
5export default () =>
6 <>
7 <Nav />
8 <p>Index page</p>
9 </>
1// src/app/pages/About.js
2
3import Nav from '../components/Nav.js'
4
5export default () =>
6 <>
7 <Nav />
8 <p>About page</p>
9 </>

The static directory is where your app can store static files like favicon.ico or robots.txt. Having any file here is necessary for defining Firebase hosting route configs. placeholder.json is an empty file here just for this purpose.


Create the serverless functions

Now let’s add serverless functions to handle each app route:

1|-node_modules
2|-src
3| |-app
4| | |-components
5| | | `-Nav.js
6| | `-pages
7| | |-About.js
8| | `-Index.js
9| `-functions *new*
10| `-index.js *new*
11|-.firebaserc
12|-package-lock.json
13`-package.json

Inside the newly minted functions directory, we’ll have a single index.js file that serves as a function manifest for our route functions. This file will export each individual named route, as you can see:

1// src/functions/index.js
2
3// Here's firebase dependency for handling HTTP requests
4const onRequest = require('firebase-functions').https.onRequest
5
6// These relative paths will exist after compiling everything
7const index = require('./next/serverless/pages/index')
8const about = require('./next/serverless/pages/about')
9
10// These named exports will map to Firebase Function names
11exports.index = onRequest((req, res) => index.render(req, res))
12exports.about = onRequest((req, res) => about.render(req, res))

Note: index.js is one large bundle. Don't mistakenly believe that each export is its own bundle!


Compiling and deploying!

Our super simple app and super simple route functions are ready. Now we can put the pieces together and get everything running in Firebase.

First, we’ll create a config file for Next.js:

1// src/app/next.config.js
2
3module.exports = {
4 target: "serverless",
5 distDir: "../../dist/functions/next"
6}

Great, we’ve configured Next.js to bundle each page individually and where to put those bundles.

Now, we’ll define some simple scripts and include those in our package.json file so that we can automate some common tasks:

1{
2 "scripts": {
3 "build:app": "next build src/app",
4 "dev": "next src/app"
5 },
6 "dependencies": {
7 "firebase-admin": "^7.0.0",
8 "firebase-functions": "^2.2.0",
9 "next": "^8.0.3",
10 "react": "^16.8.3",
11 "react-dom": "^16.8.3"
12 },
13 "devDependencies": {
14 "firebase-tools": "^6.4.0"
15 },
16 "engines": {
17 "node": "8"
18 }
19}

"build:app" is self-explanatory. This will build out our app using direction from the src/app/next.config.js file we created. Go ahead and run it now with npm run build:app if you wish. You’ll notice a new dist directory at your project root. You can find your app page bundles nested within that.

dev is even simpler. You can now use npm run dev to hack away at your app with a modern local development server.

Let’s install a few more dependencies to make our lives easier:

npm install cpx rimraf --save-dev

I like using cpx for recursive file copying and rimraf for recursive file deletion, because they work just as well on my Mac OS X and Windows machines.

Update package.json with some more scripts:

1{
2 "scripts": {
3 "build:app": "next build src/app",
4 "build:functions": "cpx \"src/functions/**/*.*\" dist/functions",
5 "build:public": "cpx \"src/app/static/**/*.*\" dist/public/static",
6 "clean": "rimraf dist",
7 "copy:deps": "cpx \"*{package.json,package-lock.json}\" dist/functions",
8 "dev": "next src/app"
9 },
10 "dependencies": {
11 "firebase-admin": "^7.0.0",
12 "firebase-functions": "^2.2.0",
13 "next": "^8.0.3",
14 "react": "^16.8.3",
15 "react-dom": "^16.8.3"
16 },
17 "devDependencies": {
18 "firebase-tools": "^6.4.0"
19 },
20 "engines": {
21 "node": "8"
22 }
23}

build:functions doesn’t build functions necessarily. In this simple project we’ve written Node.js 8 compatible code so it’s enough to copy our functions over to dist as they are.

build:public copies static assets from the src/app/static directory. Our lone placeholder.json file will be served by our app’s domain once deployed.

clean is for deleting our dist directory to ensure we are properly building our app from scratch before each deploy.

copy:deps is interesting. It copies our dependencies over to the function directory. This ensures that we are using the same dependencies in our serverless functions and client code. One would have to define a separate set of npm dependencies within the src/functions directory without something like this.

The final piece of the puzzle is the firebase.json file, which we’ll create now:

1{
2 "functions": {
3 "predeploy": "npm run clean && npm run build:app && npm run build:functions && npm run copy:deps",
4 "source": "dist/functions"
5 },
6 "hosting": {
7 "predeploy": "npm run build:public",
8 "public": "dist/public",
9 "rewrites": [
10 {
11 "source": "/about",
12 "function": "about"
13 },
14 {
15 "source": "**/**",
16 "function": "index"
17 }
18 ]
19 }

functions.predeploy runs our package.json scripts in order to ensure that each deploy is reproducible.

functions.source points to the dist/functions directory as the source of our serverless functions. Our compiled manifest file, dist/functions/index.js, sits in that directory and its named exports are our functions.

hosting.predeploy contains the package.json script responsible for setting up our static assets.

hosting.public points to dist/public, which is the directory that will have its contents uploaded as static assets.

hosting.rewrites is a mapping of URL endpoints to functions. This configuration points /about to the about page function, while all other routes will be served by the index page function. You can think of this config as your router!

With the addition of firebase.json, our directory structure resembles this:

1|-node_modules
2|-src
3| |-app
4| | |-components
5| | | `-Nav.js
6| | `-pages
7| | |-About.js
8| | `-Index.js
9| `-functions
10| `-index.js
11|-.firebaserc
12|-firebase.json *new*
13|-package-lock.json
14`-package.json

And we can now deploy our entire project to Firebase.

npx firebase deploy

Firebase will deploy our static code (placeholder.json), deploy our serverless functions, and rewrite our app routes to be served by those same functions.

At the end of the deploy you will see a Hosting URL for your project, something like https://[project-name].firebaseapp.com. Check it out!

One thing you’ll notice if you haven’t dealt with serverless applications before is that initial visits to routes will feel slower because the functions serving those routes go through a cold start if they haven’t been used recently. If a function has been invoked recently, it’s considered hot and will respond much more quickly. Having regular traffic to your functions will keep them hot. Thankfully, Next.JS’s serverless mode optimized for cold starts.


That's it! Please let me know if you found this post helpful at all! 😃


Thanks