Or how to speed up the deployments by avoiding to build your application for every environment separately

In one of my posts, I described how to Configure Nuxt.js to consume ENV variables during run-time. Nuxt.js is for Vue what Next.js is for React. Both of these frameworks are used for server-rendered or statically-exported apps. Because of their isomorphic nature, managing ENV variables is more involved than in a strictly speaking front-end, client-rendered application. Unfortunately I haven't had chance to play with Next.js yet, but I got an idea for writing this post from my previous post on How to use Google Analytics with React Router v4 where I am using window._env_ to access my ENV variables instead of more commonly used process.env using Webpack's EnvironmentPlugin. Hope this is useful for others as much as it was for my team and me.

Why do this at all?

If you want to "build once, deploy many", you generally have to re-invent the wheel in JavaScript (front-end) world since the trend seems to be build for each environment separately and "bake" everything inside your bundle(s).
What does "build once, deploy many" strategy even mean? Imagine that you have your shiny application that you and your team work on and you want to deploy it to many different environments - test, dev, staging, production,... Most likely you will always be running a production build for all of them. Yet, if you are using CRA out of the box none of them are going to result in the exact same bundles. Why? Because Webpack will "bake in" process.env variables depending on the environment where the build was run. This is unnecessary and slow. The approach demonstrated here keeps the entire application build intact and runs a script to generate a separate file that will declare a global window variable (object) that contains all ENV variables specific to that environment only.

Dive right in

It turns out this is also quite easy to accomplish since we will take advantage of the heavy lifting that CRA already does to prepare the variables for the already mentioned EnvironmentPlugin. Keep in mind that CRA uses convention to expose any variable to the client that starts with REACT_APP (ENV and PUBLIC_URL being exceptions).

Writing the script.

// scripts/client-env.js
const fs = require('fs')
const clientEnv = require('react-scripts/config/env.js')(process.env.PUBLIC_URL || '')

const pathToWrite='./public'
const fileToWrite = `${pathToWrite}/client-env.js`
const globalVarName = '_env_'
const content = `window.${globalVarName} = ${JSON.stringify(clientEnv.raw)}`

try {
  fs.writeFileSync(fileToWrite, content, 'utf8')
} catch (err) {
  // eslint-disable-next-line no-console
  console.log('Error while writing client-env file:', err.message)

First we require dotenv so that we have access to our ENV variables through process.env. Require fs for writing the file and then the most important line - declaring clientEnv. Here we require a script from CRA (react-scripts in node_modules directory inside your project) and execute it in the same line. The function that config/env.js file exports takes only one argument (publicUrl). We read this from the current environment and if not defined, pass an empty string.
The rest of it should be self-explanatory.

Depending on your needs, you could allow the script to take command line arguments. Instead of hard-coding the pathtoWrite, fileToWrite, globalVarName, you could do something like this:

 * Remove first two arguments
 * 1st arg: path to nodejs
 * 2nd arg: location of this script
const args = process.argv.slice(2)
const DIR = /DIR=/i
const pathToWrite = args
  .filter(key => DIR.test(key))
  .slice(0, 1)
  .reduce((prev, curr) => prev + curr.replace(DIR, ''), '')

if (!pathToWrite) {
  throw new Error('DIR argument is required.')

Plug it in

Now that we have our script, how/when do we run it and how do we consume it? Consuming it is more straight forward so let's address that first.
This file needs to be loaded before the bundle file so head to your public/index.html file and add <script src="%PUBLIC_URL%/client-env.js"></script> just before the closing </body> tag.

Now that we got that out of the way, let's think where will this need to be executed. For starters, development. So let's add the following line to our package.json file inside "scripts" object:

"build:env": "node scripts/client-env.js DIR='./public'"

Now for development, instead of "start": "react-scripts start", we will have "start": "npm run build:env && react-scripts start" which will generate the file every time we execute npm run start.

For other environments, it really depends on your use-case. In most projects we use, there is also a server so we have something like this:
"start": "npm run build:env && NODE_PATH=server-build node server-build/index.js" which generates the file just before the server (re)starts. So your mileage may vary. You could, for example, modify this approach for every client-env.js to be generated centrally in one location for every environment you need it for.

There are, of course, drawbacks to this approach:

  • It is an extra file so your browser may choke sooner if it hits the maximum concurrent connection limit to the same domain
  • Your ENV variables are more exposed. Given that this is a browser, you should not be too worried about this since you should not expose anything sensitive there anyway (if you are, please remove the sensitive info ASAP)
  • Extra configuration. While this reuses the functionality already built-in inside CRA (and just exposes it differently), it is still an extra factor to worry about when it comes to maintainability/portability
  • ENV variables are still going to be baked inside process.env, depending on where the production build was run. If you don't consume that, it should not be a problem (aside from the unnecessary bloat in your bundle file(s)). This can be avoided, but requires eject-ing.

We deploy often and having a finished deployment in most cases under 30 seconds makes this approach worth its drawbacks for my team and me.

In and out. Ciao!