24 June, 20236 minute read

How to verify environment variables are set in Next.js

The “Twelve-Factor App” methodology dictates that all app configuration is stored in environment variables which completely separates configuration from code. The advantage of this approach is that you can deploy a single build artifact to all of your dev/staging/production environments instead of needing to build N artifacts. This yields a significant reduction in your CI/CD’s compute usage, and means that a source code leak won’t expose any sensitive information such as database credentials.

The problem introduced by this methodology is that—out of the box—environment variables aren’t actually guaranteed to exist. Most applications will validate the contents of their environment at runtime using a library like envconfig, but if an error is caught at that point it means we’ve shipped a buggy app and earned some frustrated customers.

If you’re managing your own infrastructure then building a system that statically verifies your application has all its needed environment variables can be challenging to do—there’s no off the shelf tool that will extract a schema from your application code and then run that schema against your AWS infrastructure or Terraform.

If you’re using a modern PaaS like Vercel, on the other hand, solving this problem is trivial. Vercel guarantees that the environment of your application at build time matches the environment of your application at runtime1, which means all we need to do is validate our environment variables when our app gets built. If you’re using Next.js, then this is also really easy to do and will only take a few minutes to get up and running.

How to solve the problem

The key thing to realize is that the Next.js configuration file—next.config.js—is simply a plain old JavaScript file that gets required by the Next.js CLI whenever you build or run your project. You can insert any arbitrary code into this file and that code will wind up executing at build time. This is useful for a variety of different purposes, but today we’ll focus on validating environment variables.

I generally like placing a configuration file in src/server/ which parses process.env at runtime. If you write this file in JavaScript—not TypeScript—and require it from within next.config.js, then that’s all you need to do. Here’s an example using zod for validation:

Click to copy
// @ts-checkconst { z } = require('zod'); const Config = z.object({  DB_URL: z.string(),  OPENAI_API_KEY: z.string(),}); const config = Config.parse(process.env); module.exports.config = config;

After writing this file, you’ll want to ensure that both the allowJs and checkJs flags are set to true inside your tsconfig.json. The allowJs flag is required so that you can import the config object from config.js, and the checkJs flag enables some additional type safety which we’ll explore soon. The last step remaining to wire this logic into your build step is to add require('./src/server/config.js') to the top of your Next.js configuration file.

Note that it’s important your file is JavaScript and not TypeScript, because next.config.js does not get transpiled. If you attempt to import a TypeScript file here then you’ll get a nasty build error.

At this point you’re done. It’s now impossible to deploy a version of your app with missing environment variables. If you are so inclined, however, there are a few extra things you can do here to make the developer experience even nicer.

Type checked frontend environment variables

I place this configuration file under src/server/ because environment variables without a NEXT_PUBLIC_ prefix don’t get exposed to the frontend for security reasons. Because of this, importing from config.js in the frontend will always explode because our code in that file is validating non-NEXT_PUBLIC_ environment variables.

You can fix this by adding a second src/client/config.js file following the same structure as the one on the server, but the downside to that approach is you’ll wind up bundling zod into your frontend. zod weighs in at about 13 kB minified + gzipped, which is pretty substantial.

The solution here is twofold:

  1. Add your NEXT_PUBLIC_ variables to your src/server/config.js file. Backend code receives all environment variables, and not only ones without a NEXT_PUBLIC_ prefix.
  2. Augment the global ProcessEnv interface to include your variables.

The first step is easy to do, but the second one might be unclear if you aren’t an experienced TypeScript user. Declaration merging is a powerful TypeScript feature which allows you to extend interfaces (among other things) exported by third-party libraries, and we can also use it to extend built-in types like NodeJS.ProcessEnv; which is the type of the process.env global.

Click to copy
declare global {  namespace NodeJS {    interface ProcessEnv {      NEXT_PUBLIC_STRIPE_PUBLIC_KEY: string;    }  }}

If you add that bit of code to your application, you’ll find that when you type process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY you get a string type back instead of string | undefined. Performing this interface augmentation naively would be dangerous, but in our case we’ve ensured that the variable exists at build time.

This does open you up to a few new footguns compared to simply adding a client-side zod type, however.

  1. It’s possible to specify an environment variable inside config.js and forget to update your ProcessEnv augmentation
  2. It’s possible to add an environment variable to your ProcessEnv augmentation and forget to add it to config.js

The first issue isn’t too bad, because at least your types aren’t advertising something that doesn’t exist. The second issue is pretty bad however, because when writing code that uses this unvalidated environment variable your inclination is to trust your types.

We can actually solve this problem without needing to add zod to our bundle, though. We just need a little bit more TypeScript:

Click to copy
// ... /** @type {Array<keyof NodeJS.ProcessEnv>} */const x = []; /** @type {Array<keyof z.infer<NodeJS.ProcessEnv>>} */const y = x; // ...

The keyof operator returns a string union type containing all keys of the provided type. What this code excerpt does is verify that an array containing the keys found on ProcessEnv can be assigned to an array containing the keys found inside our Config type. If the ProcessEnv interface has an additional key(s), then that means the string union type of x will be wider than the string union type of y, and the assignment will fail. Note that you’ll only get a type error here if you’ve enabled checkJs inside your tsconfig.json file!

The same can be done in reverse if you’re looking to ensure that your zod type doesn’t contain extraneous keys.

Note that while we’ve solved the problem of our two object shapes getting out of sync with each other, we do lose functionality compared to bundling zod on the frontend. zod has a number of features for refining and transforming the values it’s parsing, and we lose access to those if we’re reading directly from process.env. If you really need these features, then bundling zod on the frontend isn’t the end of the world.

Poor performance is poor accessibility, however, and slimming down your bundle size is one of the easiest and most immediate ways you can improve the performance of your web app. Leveraging the type system instead of runtime validation is always going to result in a smaller bundle.

A final consideration here are our error messages. The config.js file is guaranteed to fail if it’s included on the frontend because we’re validating non-NEXT_PUBLIC_ variables and so we can add a helpful error message for when someone accidentally imports it:

Click to copy
// ... if (typeof window !== 'undefined') {  // Come up with your own helpful error :)  throw new Error('src/server/config.js should not be imported on the frontend!');} const Config = z.object({// ...

Even stricter validation

While the original config.js file is all you need to validate your environment variables, the error messages can be a bit obtuse and the level of validation isn’t fantastic. It’s possible to pass foo as a value to DB_URL and our parser will happily accept it even though such a value will obviously cause an error when we try to use it at runtime.

If you aren’t familiar with zod, the library includes a number of built-in features to enhance the value of your validation. Here are two easy wins we can get:

Click to copy
// ... const Config = z.object({  // Apply a regex, and add a helpful error  DB_URL: z.string().regex(/^(postgres|postgresql):\/\//, {    message: 'DB_URL is not a valid postgres connection string',  }),  // Don't like regexes or need more complex validation?  // Use a refinement function  OPENAI_API_KEY: z.string().refine((it) => it.startsWith('sk-'), {    message: 'OpenAI API keys must start with the prefix `sk-`',  }),}); // ...


Next.js makes it really easy to run code a build time because next.config.js gets imported by the CLI and can contain arbitrary JavaScript code. This enables powerful developer experience enhancements which make your code safer and your team more productive. Validating the existence of environment variables at build time is a quick and easy win that can massively reduce the amount of time spent investigating broken builds.

  1. An edge case that’s easy to miss here is engineers changing environment variables after the build happens. Vercel handles this without any effort on your part. If you change an environment variable after a deploy is made, then you need to actually need to redeploy before those changes get applied—this is the key thing that makes validation of environment variables possible to do at build time.

Don't want to miss out on new posts?

Join 100+ fellow engineers who subscribe for software insights, technical deep-dives, and valuable advice.

Get in touch 👋

If you're working on an innovative web or AI software product, then I'd love to hear about it. If we both see value in working together, we can move forward. And if not—we both had a nice chat and have a new connection.
Send me an email at hello@sophiabits.com