Runtime environmental variables in Next.js 14

michaliskout

Michael

Posted on April 25, 2024

Runtime environmental variables in Next.js 14

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

  1. The Goal
  2. The Solution
  3. Testing
  4. Cons
  5. Conclusion

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.

Build once, deploy many strategy

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.

Solution graphic example

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),
      }}  />;

}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
};

Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

ā„¹ļø 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>;
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
        </>
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
})
Enter fullscreen mode Exit fullscreen mode

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.

  1. 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.
  2. It has a performance cost for the application because the provider must hydrate components with the new configuration values during the 'componentDidMount' lifecycle event.
  3. 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.

šŸ’– šŸ’Ŗ šŸ™… šŸš©
michaliskout
Michael

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