Using runtime environment variables on static pages in Next.js.
- Processes, standards and quality
- Technologies
- Others
Runtime environment variables provide a way to dynamically influence the functionality of an application. They are often used to configure connections to external services or enable certain features based on the current environment. The mechanism allows, for example, to have multiple deployments of the same build, each connected to a different ecosystem, or to change the way an existing deployment works without having to re-build the solution.
Although it is most commonly associated with backend applications, the use of runtime environment variables is also a viable option on the frontend side, thanks to the Server Side Rendering (SSR). The name describes an application’s ability to render browser content on the server side and then send it to the client, as opposed to sending JS code to the browser and letting it handle the rendering process (known as Client Side Rendering). SSR allows for dynamic access to runtime environment variables, unlike CSR, where the environment needs to be known at build time. This article describes how to utilize runtime environment variables in a Next.js application in a slightly different way than the most popularized one.
The problem
Next.js is a React framework prioritizing performance and leveraging SSR to achieve fast loading of web pages. One of the mechanisms for improving load times is Automatic Static Optimization (ASO). In short, the mechanism detects which pages in the application are independent from the server data and pre-renders them during the initial build. When clients request such pages, the server is able to immediately send back the response without any need for additional computation. This is great for performance, but a problem arises when the page requires access to the runtime environment, because the values it provides are not known at build time.
The solution?
At the time of writing this article, the official solution to the problem described in Next’s docs is to disable ASO for each page requiring access to runtime environment variables by defining the getInitialProps function. Such page is rendered by the server each time it is requested and as such is granted access to the dynamic data present on the server. It may be a viable option to go with but significantly longer loading time caused by the fact that the server has to perform computations in order to render the page is a major downside. If there is no other reason for a page to have ASO disabled, such loss in performance may seem unjustified.
A different way
We have recently faced the problem concerning runtime environment variables in our project. The premise was that the variables were needed to store endpoints for various services (backend application, logging, authentication). These values needed to be present in almost every single page and had to be configurable between different deployments of the same Next.js build. We initially considered going with the popular solution described in the previous paragraph; however, this idea was quickly rejected as performance was of paramount importance to the project. Another option was to create dedicated builds for each deployment, with specialized pipelines injecting different environment variables to each build. However, this solution would complicate the CI/CD pipelines, increase the maintenance costs and take away the ability to switch between external services without re-deploying the applications. Being seemingly stuck between a rock and a hard place, we started searching for other alternatives.
The solution came in form of Next’s API routes. In addition to serving web pages, Next.js server is able to expose a dedicated REST API. The code within the controllers is run on the server and as such has full access to the environment variables. With this in mind, we developed a solution based on a simplified algorithm:
- On the first visit using a given device:
1. Asynchronously call the API, requesting runtime environment variables,
2. Store returned values in a React context and inside cache (local storage). - On a subsequent visit using a given device:
1. Populate React context with values stored in cache,
2. Asynchronously call the API, requesting runtime environment variables,
3. Compare values returned by API with the ones stored in cache and if:
– all values match – do nothing,
– values differ – updated context and cache with the new values.
Such solution would maintain the possibility of using ASO for pages while also enabling us to dynamically manage the variables between multiple deployments, without re-building the application.
Implementation
This paragraph describes the process of implementing the solution, along with code snippets. A full example can be found in this Github repository.
The first step is creating an .env.local file, defining the environment variables (in production, these values would probably be injected by an application service/container).
# env.local
MY_RUNTIME_ENV_VAR=my-test-value
Next, the variables need to be exposed to the application. This is done through entries in next.config.js file. The variables can be passed through serverRuntimeConfig, since they will only be directly accessed by the Next’s API.
// next.config.js
/** @type {import("next").NextConfig} */
module.exports = {
reactStrictMode: true,
serverRuntimeConfig: {
myRuntimeEnvVar: process.env.MY_RUNTIME_ENV_VAR,
},
}
Having the variables exposed, an API endpoint returning them can be implemented.
// pages/api/runtime-config.ts
import { NextApiResponse } from "next";
import getConfig from "next/config";
const handler = ({} = {}, res: NextApiResponse) => {
res.status(200).json({ ...getConfig().serverRuntimeConfig });
};
export default handler;
The next step is to add a cache in order to make access to these variables more efficient.
// src/utils.ts
import { RuntimeConfig } from "./types";
export const runtimeConfigStorageName = "runtime-config";
export const getCachedRuntimeConfig = (): RuntimeConfig | undefined => {
if (!global.localStorage) {
return undefined;
}
const config = localStorage.getItem(runtimeConfigStorageName);
if (config) {
return JSON.parse(config);
}
};
export const setCachedRuntimeConfig = (config: RuntimeConfig) =>
!!global.localStorage &&
localStorage.setItem(runtimeConfigStorageName, JSON.stringify(config));
Lastly, a context provider, which is responsible for fetching runtime environment variables from API / cache and exposing them to the components, is implemented.
// src/runtime-config-provider.ts
import { createContext, ReactNode, useEffect, useState } from "react";
import { RuntimeConfig } from "./types";
import { getCachedRuntimeConfig, setCachedRuntimeConfig } from "./utils";
export interface Props {
children: ReactNode;
}
export const RuntimeConfigContext = createContext<RuntimeConfig>({});
const RuntimeConfigProvider = ({ children }: Props) => {
const [runtimeConfig, setRuntimeConfig] = useState(getCachedRuntimeConfig());
useEffect(() => {
if (!!global.window) {
const execute = async () => {
try {
const response = await fetch(
`${window.location.origin}/api/runtime-config`
);
if (response.status === 200) {
const configJson = await response.json();
if (
!runtimeConfig ||
JSON.stringify(configJson) !== JSON.stringify(runtimeConfig)
) {
setRuntimeConfig(configJson);
setCachedRuntimeConfig(configJson);
}
}
} catch {}
};
execute();
}
}, [runtimeConfig]);
return (
<RuntimeConfigContext.Provider value={runtimeConfig ?? {}}>
{children}
</RuntimeConfigContext.Provider>
);
};
export default RuntimeConfigProvider;
Pitfalls
The proposed solution obviously has some drawbacks and it is important to be aware of them when choosing an approach to the problem. First of all, when fetched from the API, the values of variables appear in the application with some delay, at least on the first load (before they get cached). This requires the consuming logic to be able to handle changes of values after the initial render in a graceful manner. This may be especially challenging when a given page performs complex computations during the render and the environment variable is consumed within a top-level component. In such a case, a change in the variable’s value triggers a costly re-render of the entire page.
Another variation of the described issue is a situation where a value under a given key already exists in cache, but the actual value on the server is different. Then, in the application, there will be a short time window, right after the initial render, when the environment variable will have an incorrect value. This enforces a need to implement additional validations / error handling, accommodating the possibility of incorrect environment values being present in the application.
Finally, there is an important aspect of security and privacy. Creating an endpoint returning runtime environment variables publicly exposes the values to everyone, unless additional access control is implemented. Therefore, the solution, in its presented form, should only be used for non-secret variables.
Conclusion
There are various ways of handling runtime environment variables in a Next.js application, each having its own advantages and drawbacks. The solution described in this article fits the requirements of a certain project and can be applied elsewhere, but it is far from being a universal remedy to the problem. Ultimately, making a decision on which solution to go with should always depend on the requirements presented and should take them into account to the greatest extent possible.