React is as popular as ever and is accompanied with tools as create-react-app and NextJs. In addition to this we have the cloud industry which seems to have embraced containers everywhere. Surprisingly there is some friction in configurability, the place where we'd least expect it.
The twelve-factor app methodology advises to store application configuration in environment variables. This strict separation of config from code allows easy variations between deploys (to staging, production, etc) without ending up with an explosion of combinatorial configuration or hardcoded constants. This advise is well adopted in the cloud community and even create-react-app and NextJs added support for environment variables.
While the world seems to live in harmony there is one important caveat: both web technologies embed environment variables at build-time. This is well intended as static bundles enable us to leverage CDNs to enjoy low-latency in every corner of the world. The other side of the coin is that builds no longer allow easy variations between deploys. The following options can inspire you when this becomes a requirement.
Build per environment
In this solution we embrace that configuration is baked into the image. It favours scalability over configurability and would in most cases be my recommendation. Its weakness appears when the team who builds the application does not run it. In this case, even the smallest tweak requires knowledge of the build process or depending on the responsible team.
Implementation will require additional effort in your CI/CD pipeline to create and push multiple builds. A best practise on Google Cloud Platform is to use a project per environment. Consider to mirror your source repository to each project, build the application with Cloud Build and afterwards push it to the project's Container Registry (NextJs) or Cloud Storage (CRA). The simplification in identity and access management is worth the effort.
Server-side configuration
In NextJs you can choose runtime configuration over build-time environment variables. This feature passes configuration to a page whenever a request is made to the server. Server-side configuration is naturally impossible for create-react-app and even in NextJs it will penalise latency. The impact on user experience is most often a bigger concern than the inconveniences that come with build per environment.
Latency is impacted because passing along configuration on server requests implicates incompatibility with automatic static optimisation. It's this optimisation that would've allowed you to push your pages down to a CDN. While static site regeneration might diminish the impact it will always remain a subpar solution. To end on a positive note, runtime configuration in NextJs is super straightforward which decreases time to implement.
Client-side configuration
This solution leverages Kubernetes' ability to mount files at any location in the file system. The feature is commonly used to configure applications that opt for configuration files over environment variables. You can consider this over build per environment for create-react-app, though unfortunately it tends to get a bit fussy in NextJs due to configuration only being available in the browser.
Besides that, realisation is a bit tricky as we have to work against our application's bundler such as WebPack or Parcel. To properly mount the file you'll have to exclude it from the bundler or it will disappear in one of the many chunks. We're left with a slightly more complicated implementation as seen below.
// public/config.js
// By default this should contain development configuration.
// Afterwards use a Kubernetes ConfigMap to mount over this file.
window.appConfig = {
ANALYTICS_ID: 'foobaz',
};
// index.html (CRA) or _document.ts (NextJs)
// It's essential that this script is loaded before your bundle.
<head>
<!-- ... -->
<script src="/config.js" />
<!-- ... -->
</head>
// src/config.ts
// Optional step for proper type-safety.
// You can drop the 'client' property in create-react-app.
declare global {
interface Window {
appConfig: {
ANALYTICS_ID: string;
};
}
}
export const config = {
client:
typeof window === 'undefined'
? undefined
: {
analyticsId: window.appConfig.ANALYTICS_ID,
},
};
// src/app.ts
import { config } from './config';
initialiseAnalytics(config.client!.analyticsId) // Requiring a non-null assertion on the 'client' property mitigates that developers use these values on the NextJs server.
Build at startup
The final and simplest alternative is to build the application at deploy-time. That way building an image is nothing more than putting source files and dependencies in a box. Afterwards you can tweak environment variables and the moment you start the container it will eventually create a build, immediately before starting the server.
Despite simplicity, the bitter consequence is that startup time takes a drastic hit, development dependencies will eat a few hundreds of megabytes and without Kubernetes pod resource limits you might starve other pods during builds. This solution should generally be avoided.
Closing thoughts
While no option is ideal, software design and architecture is nearly always a trade-off in quality attributes such as scalability, configurability, etc. Once you accept that there are no silver bullets you can set your priorities and choose a fitting solution.