Setting Environment Variables for Runtime and Build Time in Next.js.

The modern best practice for DevOps is to build the application once. Then deploy the same application with different configurations to different environments.

The typical way to do that is to pass Environment Variables from the host system to the application.

All this is straightforward if the application is running only on the server.

In the case of Next.js, the application spans both client and server. Which makes things a little more complicated.

Next.js does compiler magic to separate the code that needs to run on the client or server or both (SSR + hydration)!

With this pretext, let's say we try and access the environment variable in a component:

// pages/index.js

function HomePage() {
  return <h1>{process.env.ENVIRON_PROPERTY}</h1>;
}

What do you think will happen if you run this code?

You will get a hydration error (only on dev)!

As on the server process.env.ENVIRON_PROPERTY will be resolved to "some value" but on the client, it won't be.

This mismatch will result in a hydration error.

Up until Next.js 9 we had two configuration options publicRuntimeConfig and serverRuntimeConfig.

This made things quite clear, We could configure a variable something like this:

// next.config.js

const config = {
  // if variable is needed in both client and server runtime.
  publicRuntimeConfig: {
    ENVIRON_PROPERTY: process.env.ENVIRON_PROPERTY,
  },

  // only on server runtime!
  serverRuntimeConfig: {
    SERVER_SPECIFIC: process.env.SERVER_SPECIFIC,
  },
};

Now to access the variable on the page:

// pages/index.jsx

import getConfig from "next/config";

const { publicRuntimeConfig, serverRuntimeConfig } = getConfig();

function HomePage() {
  return <h1>{publicRuntimeConfig.ENVIRON_PROPERTY}</h1>;
}

Run this again and the page will work fine!

But if you try and access the server-specific variable it will fail.

Server-specific variables can be used inside getServerSideProps and the API paths. They run only on the server.

The above approach works fine mostly but it has been sunset since Next.js 11 in favor of this.

On the surface, the new approach seems straightforward i.e. For env variables needed on the browser prefix them with NEXT_PUBLIC_ otherwise it's server specific.

The caveat here is that browser-level environment variables are replaced during build time.

So let's take an example where you have an API URL that you want to configure as an environment variable:

const apiUrl = process.env.NEXT_PUBLIC_API_URL

fetch(apiUrl).then(someLogic)

And you have the environment variable set as

export NEXT_PUBLIC_API_URL="http://mycool/api"

Now once you do a next build the above code will become:

const apiUrl = "http://mycool/api"

fetch(apiUrl).then(someLogic)

This works fine. But now let's say you want to deploy this application to staging where the API URL might be different:

export NEXT_PUBLIC_API_URL="http://mycool-staging/api"

Now you run the app again with this, and behold the URL won’t change.

So to get this working as expected you would have to build again.

Which goes against the DevOps best practice. As we must build the image once and deploy the container to different environments (with different env variables).

So, how do we get around this?

It's clear that the NEXT_PUBLIC_ variables aren't the right fit for the job.

So we have to rely on server-side env variables. But how do we access them on the client?

Well for that we have getServerSideProps to the rescue:

export async function getServerSideProps() {
  return {
    props: {
      runtimeConfig: {
        ENVIRON_PROPERTY : process.env.ENVIRON_PROPERTY
      },
    },
  };
}

And you consume it as props on the page.

Most applications would need this to be available across the app. Creating a React Context makes the most sense like this:

import React, { useContext } from "react";

// where your config is stored
import { RuntimeConfig } from "@/environment-config/runtime-config";

const RuntimeConfigContext = React.createContext<RuntimeConfig | null>(null);

function RuntimeConfigProvider({
  children,
  runtimeConfig,
}: {
  children: React.ReactNode;
  runtimeConfig: RuntimeConfig;
}) {
  return (
    <RuntimeConfigContext.Provider value={runtimeConfig}>
      {children}
    </RuntimeConfigContext.Provider>
  );
}

function useRuntimeConfig() {
  const context = useContext(RuntimeConfigContext);
  if (context === null) {
    throw new Error(
      "Wrap the component in RuntimeConfigProvider with proper config",
    );
  }
  return context;
}

export { RuntimeConfigProvider, useRuntimeConfig };

And consume it in a component like this :

function SomeComponent() {
  const { ENVIRON_PROPERTY } = useRuntimeConfig();
  return <h1>{ENVIRON_PROPERTY}</h1>;
}

With these things in place, your Next.js app is set for dockerizing. The image only needs to be built once. You can deploy it anywhere with different environment variables as it fits.

In fact with this in place, you can create a standalone output.

You need to set the output mode in next.config.js as standalone

And you are all set.

When you run the build now - Next.js will trace only the source code it needs and spit it out in the build folder. And you need only node to run the application (no npm required).

There are a bunch of advantages to this:

  • Much much smaller docker image size (I saw almost a 50% reduction)

  • Enables you to use distroless images that are a lot more secure.

Some extra pro tips :

  • You can use something like Zod. To add validation and add type safety to your environment variables. (The app will fail fast if the wrong config is present)

  • Using t3-env to make the DX even better.

For full code examples: