Arbeit

NextJS Environment Variables in Containers

Are you working with Containers, that you build once and then push through the various stages?

If so, then this might be for you as you probably also need some if not all of your env variables available on the client side as well. Honestly I was expecting this to be the case when using NEXT_PUBLIC_* as a prefix for them…

What NextJS offers out of the box

Configuration is most of the time added at build time for next, this changed over time as originally all of them where available through the publicRuntimeConfig and serverRuntimeConfig. Having things at build time is sadly not really an option when deploying through containers as it is not reasonable to build every time the container starts…

For one of my applications that means that with all the build logic I have around 1.2Gb for the container, whereas when using the standalone output option the container size goes down to 82Mb!!!

What was and is now deprecated?

The old way for environment variables, the runtime config, was deprecated with the arrival of the app dir.

  • This feature is deprecated. We recommend using environment variables instead, which also can support reading runtime values.
  • You can run code on server startup using the register function.
  • This feature does not work with Automatic Static Optimization, Output File Tracing, or React Server Components.
https://nextjs.org/docs/pages/api-reference/next-config-js/runtime-configuration

How the runtime config was defined in next.config.js:

/** @type {import('next').NextConfig} */
const config = {
  publicRuntimeConfig: {
    variableForClientSide: process.env.ANYTHING,
  },
  serverRuntimeConfig: {
    variableForServerSide: process.env.ANYTHING_SECRET,
  }
}

This has been working most of the time, but also had it’s quirks, especially since some parts of the runtimeConfig would be inlined by NextJS – this would happen every time when a page is rendered and then outputted.

This caching might seem useful in some cases, but makes it really tricky to reliable get a good output. (ISR was partially helpful, but needed all paths to be accessed before any clients would be allowed to access it as it would otherwise output stale runtime variables from the build time!!)

To have this working it was required to have a getInitialProps either on every page or the _app.tsx to force rendering on build time.

Using process.env

Next.js comes with built-in support for environment variables, which allows you to do the following:

  • Use .env.local to load environment variables
  • Bundle environment variables for the browser by prefixing with NEXT_PUBLIC_
https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables

This sounds perfect at the beginning, but starts to fall apart the moment you want to deploy in any kind of containerised system. Within your application you can reference any environment variable as process.env.VARIABLE, with the note that this implies a build time replacement of the variable (except for SSR, there it might be read at runtime…).

Default Environment Variables

This is the default behaviour that I would not expect, as this will replace the variables at build time by replacing any occurrence of process.env.NEXT_PUBLIC_VAR with the actual value.

On the server side, the variables are not inlined and can still be referenced – and therefor be used especially in the app router!

Dynamic Environment Variables

If variables should not be replaced and always be loaded from the corresponding env variable then they must be defined so it seems like the name could change.

// This will NOT be inlined, because it uses a variable
const varName = 'NEXT_PUBLIC_ANALYTICS_ID'
setupAnalyticsService(process.env[varName])

// This will NOT be inlined, because it uses a variable
const env = process.env
setupAnalyticsService(env.NEXT_PUBLIC_ANALYTICS_ID)

Nevertheless this will not behave as expected on the client side as process.env will always be an empty object. (it works fine on the server side!!). DANGER!!

Make process.env work in a containerised setup

First of, what exactly do I mean by this:

  • process.env.NEXT_PUBLIC_* should reflect on client side whatever was set at container start
  • process.env.* should reflect on the server side whatever was set at container start
  • no build should be required to have capability 1 and 2!

There are many tickets in the NextJS bugtracker, but so far I have not found any that provided a solution for this. But fear not, I have found a way! (here are some of them all related to env handling, had problems with all of them at some point, some have been fixed, some need the steps that will be outlined at the end!)

Code

// file: env.provider.tsx
import { EnvProviderClient } from './env.provider.client'
import { FC } from 'react'

export const EnvProvider: FC = () => {
  const env = {}

  Object.keys(process.env).forEach(key => {
    if (key.startsWith('NEXT_PUBLIC_')) {
      env[key] = process.env[key]
    }
  })

  return <EnvProviderClient env={env} />
}
// file: env.provider.client.tsx
'use client'

import { FC, useMemo } from 'react'

interface Props {
  env: any
}

export const EnvProviderClient: FC<Props> = ({ env }) => {
  useMemo(() => {
    if (typeof window !== 'undefined') {
      global.env = env

      window.dispatchEvent(new Event('global.env'))
    }
  }, [])

  return null
}

With those 2 files we can fix the default behaviour and expose all public env variables to the client! In case you need to update configuration if env is not yet set, use an event listener!

To then enable the client side env variables you just have to add <EnvProvider /> and they will all be available.

Problem solved!! This also works with standalone builds using tracing (https://nextjs.org/docs/app/api-reference/next-config-js/output) – this will also reduce your image size dramatically!!

Leave a comment