Michael
Posted on April 25, 2024
This post will walk you through how to enable runtime environmental variables for both client and server components in a Dockerized Next.js app deployed on a custom-configured host.
Disclaimer: If you have a Dockerized Next.js application and you want to enable runtime environmental variables, then this post will help you; otherwise, if you use Vercel's cloud infrastructure for your deployments, Next.js documentation got you covered š.
Topics
The Goal ā
One of the most important rules of a 12-factor application and key to continuous integration and development (CI/CD) is the Config rule. It specifies the separation of the code and the environmental variables, which makes an app agile to configuration changes and reduces its builds.
With the release of Next.js 14 and the unstable_noStore utility, we now have a new method of handling runtime environmental variables and implementing the "Build once, deploy many" strategy. In general, the goal of this strategy is to enable us to deploy a Docker image in multiple environments (e.g. production, staging, or development) by simply changing our configuration environmental variables based on the requirements of each environment, thus preventing the need to create a new image for each.
The graphic below shows how the aforementioned strategy will function with a Dockerized Next.js 14 application.
Static vs. runtime environmental variables
To get the following behavior in a Next.js app, we need to enable runtime environments. Our app uses two types of environmental variables:
- runtime, which are evaluated during the application's runtime and should be changed dynamically each time it refreshes
- static, which are only evaluated during build time and must be rebuilt to change their value.
So, in this post, we will go over how to handle runtime variables in our app's end-to-end flow.
Until the time of writing, there is no official example of how to enable runtime environmental variables in a Dockerized Next.js app, asĀ utilizing unstable_noStore
would only dynamically evaluate variables on the server (node.js runtime). There is also an interesting discussion regarding this topic on GitHub.
Essentially, the Next.js team proposes using an app router and eventually reading runtime environmental variables on each page.tsx
route (server component) and passing those values down to the client or components using prop drilling.
ā However, what if you have multiple deep-nested client components that require access to runtime environmental variables?
We need to figure out how to pass runtime environmental variables to the browser as they change dynamically on the server so that we can have a single source of truth for our environmental variables.
The Solution ā
Now that we have a basic understanding of what we want to accomplish, we'll look at how to enable runtime environmental variables using the unstable_noStore
function.
First and foremost, we can look at the figure below, which represents the solution that we will focus on.
The graphicĀ above demonstrates how we intend to address our primary issue, which is dynamically altering environmental variables in the client's runtime.
If you checkĀ the visual example, I have highlighted the stepsĀ we will take to attain our goal.
1ļøā£ The server component will dynamically read runtime environmental variables and insert them into a script tag.
2ļøā£ We will append this script tag to the head of the HTML file, enabling it to run before the user interacts with the UI, eliminating loading states.
3ļøā£ Using dom, a React context provider will retrieve runtime environmental variables from the script tag and hydrate the related client components.
4ļøā£ To access runtime variables, client components will use the React context.
š Let's now go over each step in more detail. š
1ļøā£: Configure Server Components and Environmental Variables
Using the example from the Next.js page, let's create a server component that reads the runtime environments. To keep our configuration simple for the tutorial, we will create a config.ts
file with an object containing the values of the environmental variables. In addition, we will define a type for our configuration that reflects the types of each of our environmental variables, making variable management easier within the app. Finally, we will create the server component that will append the script tag to the DOM.
ā¹ļø Note: It's worth noting here that we also use a nonce for our script tag, which is critical if you are using CSP rules for your application and want to avoid the browser blocking it.
// config.ts
type RuntimeEnvConfig = {
my_value?: string;
app_env_name?: string;
flags: {
enableFeatureA?: boolean;
}
}
export const runtimeEnvConfig: = {
my_value: process.env.NEXT_PUBLIC_MY_VALUE,
app_env_name: process.env.NEXT_PUBLIC_APP_ENV_NAME,
flags: {
enableFeatureA: process.env.NEXT_PUBLIC_ENABLE_FEATURE_A
}
}
// EnvVariablesScript.tsx
import { unstable_noStore as noStore } from 'next/cache';
import { runtimeEnvConfig } from './config.ts';
export default function EnvVariablesScript() {
noStore();
const nonce = headers().get('x-nonce');
return <script id="env-config" nonce={nonce || ''}
dangerouslySetInnerHTML={{
__html: JSON.stringify(runtimeEnvConfig),
}} />;
}
2ļøā£: Add script tag to main layout file
The component that renders the environmental variables script should be included in the app router's main layout.tsx
files so that the script can be rendered to the domain as soon as possible. API keys or third-party integration urls, such as authentication configuration, may be environmental variables that client components should have access to before becoming interactive in order to avoid loading states. Therefore, the script tag should be included in the HTML document's <head />
section. This will include our script in the critical rendering path, requiring the browser to execute it before our web app becomes interactive for the users.
// app/layout.tsx
const RootLayout: FC<{ children: ReactNode }> = ({ children }) => {
return (
<html lang="en">
<head>
<EnvVariablesScript />
</head>
<body>
{children}
</body>
</html>
);
};
export default RootLayout;
3ļøā£: Create React context
Steps 1ļøā£ and 2ļøā£ are about the server-side work that we need to do. Now that the server work is complete, we can move on to the client-side implementation.
In this stage, we'll develop a ReactĀ context to keep the client components hydrated with runtime environmental variables.
'use client';
const defaultEnvVariables = {};
const EnvVariablesClientContext = createContext<EnvVariablesClientConfig>(defaultEnvVariables);
type EnvClientProviderProps = {
children: ReactNode;
};
export const EnvVariablesClientProvider: React.FC<RuntimeEnvConfig> = ({ children }) => {
const runtimeEnvVariables = {}; // temporary value
return <EnvVariablesClientContext.Provider value={runtimeEnvVariables}>{children}</EnvVariablesClientContext.Provider>;
};
export const useEnvVariablesClientConfig = (): EnvVariablesClientConfig => {
if (EnvVariablesClientContext === undefined) {
throw new Error('useEnvVariablesClientConfig must be used within an EnvVariablesClientProvider');
}
return useContext(EnvVariablesClientContext);
};
Based on the code example above, we created a new context, a provider, and a hook for it, allowing us to simply and safely handle the context's value across our app. For the time being, our provider will persist an empty object via runtimeEnvVariables
, as shown in the code.
4ļøā£: Handle environmental variables in client components
Now, using DOM manipulation, we can make our context provider of greater use to our app. To retrieve runtime environmental variables directly from the provider's value, we must first establish an internal state that will populate the environmental variables during React's componentDidMount
life cycle event.
First, let's define our value getter, which will retrieve the added environmental variables from the script tag.
'use client';
export const envScriptId = 'env-config';
const isSSR = typeof window === 'undefined';
export const getRuntimeEnv = (): EnvVariablesClientConfig => {
if (isSSR) return env;
const script = window.document.getElementById(envScriptId) as HTMLScriptElement;
return script ? JSON.parse(script.innerText) : undefined;
};
ā¹ļø Note: In the previous example, we used the isSSR
check to prevent our getter from running during SSR since both the client and server components will be invokedĀ and the DOM won'tĀ yet beĀ ready.
Next, we'll define the internal state that will allow us to hydrate the children's components using the runtime environmental variables on mount.
export const EnvVariablesClientProvider: React.FC<EnvClientProviderProps> = ({ children }) => {
const [envs, setEnvs] = useState<EnvVariablesClientConfig>(defaultEnvVariables);
useEffect(() => {
const runtimeEnvs = getRuntimeEnv();
setEnvs(runtimeEnvs);
}, []);
return <EnvVariablesClientContext.Provider value={envs}>{children}</EnvVariablesClientContext.Provider>;
};
Finally, we will add the EnvVariablesClientProvider
component to our app's main layout file.
// app/layout.tsx
const RootLayout: FC<{ children: ReactNode }> = ({ children }) => {
return (
<html lang="en">
<head>
<EnvVariablesScript />
</head>
<body>
<EnvVariablesClientProvider>
{children}
</EnvVariablesClientProvider>
</body>
</html>
);
};
export default RootLayout;
This way, we keep our main layout as a server component; as a result, the script tag containing our environmental variables will be generated on the server, eliminating the need for client components to wait for its rendering.
Using environmental variables in client components
We can now use runtime environmental variables on client components by invoking the useEnvVariablesClientConfig
hook. As a result, we can now deploy feature A in the demo
environment while hiding it in the production
environment, eliminating the need for separate builds for each environment. We simply need to set the related environmental variable NEXT_PUBLIC_ENABLE_FEATURE_A=true
in the environment's configuration.
const ClientComponent = () => {
const config = useEnvVariablesClientConfig();
const isFeatureAEnabled = config.flags?.enableFeatureA;
return <>
{isFeatureAEnabled && <p>feature A</p>}
...other features
</>
}
Testing š¬
Testing custom implementations, such as the current one, might get overwhelming. However, if we think of the test environment as being like a browser's DOM, we can use the same script tag that EnvVariableScript
appends to the DOM during test initĀ in the jest.setup.ts
file.
import { runtimeEnvConfig } from '...';
beforeEach(() => {
const envScript = document.createElement('script');
envScript.setAttribute('id', envScriptId);
envScript.innerText = JSON.stringify(runtimeEnvConfig);
document.body.append(envScript);
});
afterEach(() => {
document.getElementById(envScriptId)?.remove();
});
In some cases, we may want to override our environmental variables based on specific test conditions. For example, if we want to test the previous component to check if it displays the feature A UI, we can simply change the config.flags?.enableFeatureA
value for each test case.
import merge from 'lodash/merge';
const overrideRuntimeEnv = (options: any, forceOverride = false) => {
const script = document.getElementById(envScriptId);
const existingData = script?.innerText ? JSON.parse(script?.innerText) : undefined;
if (script) script.innerText = JSON.stringify(existingData && !forceOverride ? merge(existingData, options) : options);
};
it('should hide feature A when related flag is enabled', () => {
overrideRuntimeEnv({ flags: { enableFeatureA: false } });
const { container } = render(<ClientComponent />)
expect(screen.queryByText('feature A')).not.toBeInTheDocument();
})
it('should hide feature A when related flag is missing', () => {
overrideRuntimeEnv(undefined, true);
const { container } = render(<ClientComponent />)
expect(screen.queryByText('feature A')).not.toBeInTheDocument();
})
it('should display feature A when related flag is enabled', () => {
overrideRuntimeEnv({ flags: { enableFeatureA: true } });
const { container } = render(<ClientComponent />)
expect(screen.getByText('feature A')).toBeInTheDocument();
})
Cons š
Although the above solution works well for using runtime environmental variables in client components in a dockerized Next.js app, Next.js does not officially support it yet, and using unstable_noStore
will have some cons.
- It increases the complexity of the application. You should always keep in mind that you will need to maintain and use your own hook to retrieve environmental variable values.
- It has a performance cost for the application because the provider must hydrate components with the new configuration values during the 'componentDidMount' lifecycle event.
- It adds extra overhead to component testing because it introduces a new parameter to consider.
Conclusion š
This post demonstrated how to handle runtime environmental variables for a Dockerized Next.js application using a custom host. This solution, like almost every other in software engineering, has pros and cons, so please comment and provide feedback!
Thank you for reading the post. I hope this helped you solve your issue or come up with your own solution that best suits your needs.
PS: The open source package next-runtime-env provides an interesting and inspiring approach to the same problem.
Posted on April 25, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
December 6, 2023