Configuring a Single-Page-Application using Environment Variables

Why configuration for SPAs matters

When building a single-page application (or SPA) with any Javascript Framework like React, Angular or Vue (or any other of the myriad of choices) at some point the app will require a set of configuration parameters. These configuration parameters can be API endpoints or authentication credentials or simple text to be shown somewhere in the app.

As the “Twelve Factor App” puts it:

An app’s config is everything that is likely to vary between deploys (staging, production, developer environments, etc). This includes:

  • Resource handles to the database, Memcached, and other backing services
  • Credentials to external services such as Amazon S3 or Twitter
  • Per-deploy values such as the canonical hostname for the deploy

Source: 12factor.net/config

Separating the configuration of an application from its actual code is of upmost importance as it keeps the codease clean of credentials that could be exposed (e.g. in an open-source repository) and forces the developer to think which parts of the application are configuration (and thus subject to change for every deployment) and which are code (and thus stay the same on every deployment).

If the configuration parameters are not kept as part of the source code, though, where do they come from? 12-Factor-App proposes to put them into the environment and this is also how most popular web application frameworks out there handle configuration these days (usually as a combination of default parameter values that are overridden by environment variables if present).

Using Environment Variables to configure a SPA

While using environment variables to configure a server-side application is trivial, we are dealing with a client-side application where we do not necessarily control the environment but still want to be able to change configuration parameters on a per-deployment basis, e.g. if deploying the spa for different environments like staging, production, development or if providing the application as a white-label app that can be deployed by multiple customers (and multiple environments).

So how and when do we get the configuration parameters into the application?

Configuration at build time

Up to here, things seem quite trivial since you most likely already use a module bundler / build pipeline like webpack to build the SPA. Why not just let it take care of injecting the configuration parameters? It even comes with a premade EnvironmentPlugin that does exactly what we’re looking for:

It takes a list of names of environment variables (and allows to optionally define a fallback value if the environment variable is not set) and makes the value available to the SPA as a global variable.

webpack.config.js

module.exports = {
    plugins: [
        // ...
        new webpack.EnvironmentPlugin(["API_KEY", "API_ENDPOINT"]),
        // ...
    ],
};

When running a webpack build, the process.env.API_KEY and process.env.API_ENDPOINT will be set (granted that the matching environment variables are defined) and can be used like:

main.js

import axios from "axios";

axios
    .get(`${process.env.API_ENDPOINT}`, {
        headers: {
            authorization: `Token ${process.env.API_KEY}`,
        },
    })
    .then((response) => {
        // do something w/ the response
    });

This approach has one important shortcoming, though: we need to know all configuration parameters of all environments already at build time which may work if we know the environments we will deploy to beforehand but if we have a white-label SPA as described above this is not feasible and would require a rebuild every time a new deployment is to be made.

Some other alternatives that work for configuration at build-time are the excellent node-config library that also works with webpack. Use this if you know the environments you will deploy to beforehand!

If you don’t know the environments to deploy to beforehand, though, configuring the application at build-time is not a great idea. Instead you’d want to pass on configuration parameters at deployment time or runtime.

Configuration at Runtime

Configuring the SPA at runtime means that the configuration parameters (ideally stored as environment variables to comply with the 12-Factor-App specification) are being loaded once the application starts running, i.e. when the website is fully loaded and the SPA is initialized.

But since the environment in which the application runs - the browser of a user - will not contain the environment variables required for configuration, the configuration will need to be fetched from the server instead.

Fetching configuration from server

One simple approach would be to make sure the server also provides the configuration file so the SPA can load it dynamically, like:

config.json

{
    "API_ENDPOINT": "https://example.org/api",
    "API_KEY": "supersecure-api-key"
}

and the SPA would have an entrypoint that fetches this config first:

main.js

import axios from "axios";
import App from "./app.js";

axios.get("config.json").then((config) => {
    new App(config);
});

The downside of this approach is the extra roundtrip the application has to perform in order to load the config on every start (i.e. every page load).

Injecting configuration into global scope

If we wanted to remove the extra roundtrip to load the configuration, we could inject the configuration into the global scope by simply making the webserver serve the configuration as part of the html file like:

<html>
    <head>
        <title>My SPA</title>
    </head>
    <body>
        <div id="container" />
        <script type="text/javascript">
            window.configuration = {
                API_ENDPOINT: "https://example.org/api",
                API_KEY: "supersecure-api-key",
            };
        </script>
        <script type="text/javascript" src="/main.js"></script>
    </body>
</html>

which would simplify the SPA’s entrypoint to

main.js

import App from "./app.js";

new App(window.configuration);

Now we have successfully loaded / injected the configuration at runtime, but also introduced a new requirement, namely that our server needs to provide the configuration as a downloadable config file or inject it into the served html file, respectively.

If we have some server-side code anyways (looking at you: backend for frontend) this may not be a problem but what if the SPA is served by a simple and “dumb” webserver like nginx, lighttpd or apache?

In this case, it may be preferrable to provide configuration parameters at deployment time, not at runtime:

Configuration at Deployment Time

Configuring the SPA based on environment variables at the time of deployment means that we need to modify the SPA’s bundle’s source after it has been built with a module bundler like webpack. This means that the built app does not contain any configuration parameters (or only placeholders) and we need to take care of setting them at the time we upload the SPA’s files to our webserver. This could look like the following:

main.js

import App from "./app.js";

const configuration = {
    API_ENDPOINT: "${API_ENDPOINT}",
    API_KEY: "${API_KEY}",
};

new App(window.configuration);

Prior to deploying the application, we’d need to replace the placeholders ${API_ENDPOINT} and ${API_KEY} by their respsective values. Thankfully, we can automate this task without having to search and replace for the placeholders by leveraging existing tools tools like envsubst. Chances are also that we won’t deploy manually but use tools for automation like e.g. ansible which can help us with the cumbersome task.

At Innoactive, we have taken this approach and built a webpack plugin to support in the process and simplify dealing with these placeholders both during development and deployment.

Conclusion

Depending on your use-case, the above should give an overview of the various approaches on how to use environment variables to configure your SPA, no matter whether at build time, deployment time or runtime. The approach you choose will depend on your specific requirements but either way, make sure to keep your configuration parameters out of your code.