Tangible Bytes

A Web Developer’s Blog

Next.js Environment Variables

I’m running Next.js apps in production using Kubernetes - and I’ve inherited some setup that I didn’t fully understand so I’ve been investigating what is going on with environment variables.

My inherited system used a build per environment and bakes in configuration at that point.

I dislike this because …

  • I can’t test production images locally to debug
  • I like to ship the same actual tested product to prod I tested in stage
  • I want to protect sensitive data like database passwords - not store it in container images
  • I’d like to be able to make configuration changes without a full production release

While much of this is backed up by well known strategies like the 12 factor app there is always a danger that I’m just hanging on to old ways and bad habits - so the best way to make a decision is to investigate.

What’s Happening

It seems like things happen pretty much as I’d expect server side - there doesn’t seem to be a need to make in database connection details as these are read on the server at run time and work ask I’d expect.

However Nextjs is being clever on the client side - it actually inlines as much as possible at build time.

After being built, your app will no longer respond to changes to these environment variables. For instance, if you use a Heroku pipeline to promote slugs built in one environment to another environment, or if you build and deploy a single Docker image to multiple environments, all NEXT_PUBLIC_ variables will be frozen with the value evaluated at build time, so these values need to be set appropriately when the project is built.

In the source code this looks like

const API_URL = process.env.NEXT_PUBLIC_API_URL;

But the build process will replace process.env.NEXT_PUBLIC_API_URL with the actual value from the env at build time

This isn’t what I’m used to - but I can see how it optimises the client side code - environment variables are a server-side concept and in order to read live values the client would need to make a server request.

So for variables that get passed to the client and don’t change it can make sense to bundle these at build time for things like API keys that may vary by environment.

It would be possible to bake in the client side variables - but leave out the sensitive ones and pick these up at runtime from kubernetes.

getServerSideProps

One option is to use getServerSideProps()

In your page you add a parameter to your page function like

export default function ReportPage({ data }) {

and define the method to get the data

// This gets called on every request
export async function getServerSideProps() {
  const data = {
    API_URL: process.env.NEXT_PUBLIC_API_URL,
    GA_MEASUREMENT_ID: process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID,
  };

  // Pass data to the page via props
  return { props: { data } };
}

Then you can use something like data.API_URL where before you used process.env.NEXT_PUBLIC_API_URL

What happens is that it triggers the page not to be built as a static page at build time - but to be server rendered and to include the variables within the page

In my case I get inline javascript in the page which look something like this (I’ve edited it down)

<script id="__NEXT_DATA__" type="application/json">
{"props":{"pageProps":{"data":{"API_URL":"https://test.localhost"} } } } 

On the plus side my code is now fully portable - I can build one image and run it locally, in staging, or in production.

On the downside I am now getting a slower server rendered page instead of a pre-built static one.

API call

The third option is to optimise the client side code - and then make an API call to load the env data.

Create a config API page

import getConfig from 'next/config';

const { publicRuntimeConfig } = getConfig();

export default function handler(req, res) {
  return res.status(200).json({ api_url: process.env.NEXT_PUBLIC_API_URL });
}

Then call this from within your code

 fetch('api/config', { method: `GET` })
      .then((response) => {
        response.ok &&
          response.json().then((data) => {
            // do something with the config data
          }
      });

This avoids server side rendering the page - but now every single page load is making a server side rendered API call - which seems distinctly sub-optimal!

(You could set cache headers so that it doesn’t load every time - but then you have the headache of stale configs being cached after a deploy)

Conclusion

It seems like a lot of people have struggled with this issue

Ultimately this seems to be a collision between two incompatible goals and the result has to be a compromise.

On the one hand we want optimised code which minimises server loading

On the other we want fully portable containers.

We can’t have both.

Either we need client side code rendered per environment in advance or on the fly.

I think the best solution for me is likely to be to build images per environment - but to exclude sensitive data from these.

This way the client side code had things like API keys that it needs - but the sensitive things like database passwords still get loaded at runtime.

This does mean storing more container images and that I have to trust my build process because I cant run the actual image used in prod to debug.