Validating Your Environment Variables at Build Time
Today I'd like to share a simple idea with you in a short and sweet article. Not so long ago, at work, we suffered an unexpected bug when an environment variable was missing from one of our web apps. To prevent similar bugs in the future, we decided to validate our environment variables at build time. In the next few paragraphs I'll go over how we did that and discuss alternative implementations.
The Value of Validating Your Environment Variables at Build Time
It must be said that the kinds of bugs I mentioned above are rare. But, when they happen in production… they can be catastrophic. Of course these cases are unlikely to happen thanks to version control systems and code reviews, but unlikely does not mean impossible.
There's also a lot of value in doing this for your development environment.
It has often happened that I and other engineers I work with end up wasting time investigating why an app isn't behaving properly in our local environment.
Only to find out we had a misconfigured .env
file. Sometimes the .env
file was missing altogether.
This may happen more often in my case, at work.
The reason being that we host multiple apps in a monorepo and use Nx to swiftly generate new apps.
We commit a distribution version of our .env
file (.env.dist
) for every app.
Therefore, when moving from app to app you can see how there may be issues with misconfigured or missing .env
files, which are untracked by Git, not matching the .env.dist
examples.
Technical Implementation
Now on to how to actually do this in Next.js. Really it's just two steps:
- Define a schema
- Validate
process.env
in yournext.config.ts
Define a Schema
Zod is a great tool to handle this, though at work we use io-ts. Both are similar. However, in this post I'll use Zod since it's more widely used.
Now, you'll likely want to establish a convention for the name and location of your schema.
For example we created a file called dotEnv.ts
in the root of each of our application projects.
In our case let's call this file envSchema.ts
and place it in the root of our project:
import { z } from "zod"
export const envSchema = z.object({
ENVIRONMENT: z.union([z.literal("development"), z.literal("production")]),
})
Define whatever schema you need depending on the variables in your .env
file.
Parse process.env
Then in your next.config.ts
, you can simply validate process.env
against the schema:
import { NextConfig } from "next"
import { envSchema } from "./envSchema"
envSchema.parse(process.env)
const nextConfig: NextConfig = {
The difference with other articles I've seen regarding this topic is the location where we're doing the validation.
Here we're in the next.config.ts
file, and in case the validation fails, parse()
throws an error.
Effectively breaking the build process, which will also break any CD pipeline. It's weird to say but that's what we want.
I'd rather a functioning version of the app stay in production, instead of a buggy one due to misconfigured environment variables.
Besides, the error that is thrown is easy to recognize and easier to fix.
One thing to know is that the next.config.ts
configuration is loaded multiple times, sometimes by different processes.
Therefore adding lots of extra complexity may slow things down.
In our case the complexity we introduced is minimal, especially considering the benefits we get in return.
Something you may want to do is limit this logic to a specific context in which the configuration is being loaded. For that you can specify a phase in which to validate the environment.
Extra Features
You can stop here, the implementation doesn't need to get more complicated and you don't need to introduce other tools or libraries to do more. In this case only a few lines of extra code add a world of benefit as far as the robustness of our app is concerned. In fact, we haven't gone any further with this at work (yet). But, in the spirit of exploring additional benefits, I'd like to address a few extra points.
Exporting the Parsed Environment Variables
Once you've validated process.env
you can store the result and export it. Using that throughout your app instead of calling process.env
.
Personally I think this sounds good, but in practice I don't like that there's nothing preventing developers from accessing process.env
.
Having two sources of truth hurts readability and adds unnecessary complexity.
The same could be said for exporting the type of the environment variables.
Extending the global ProcessEnv
Instead of directly exporting the type of the environment variables,
you can extend the global ProcessEnv
type by telling zod to infer the types derivable from the schema we defined earlier.
This is cute since you can get nice autocomplete suggestions in your editor when accessing process.env
.
However, no matter the inferred types, the actual value will always be a string, that's just how process.env
works.
For that reason, I would avoid this extra implementation.
declare global {
namespace NodeJS {
interface ProcessEnv extends z.infer<typeof envSchema> {}
}
}
Using an External Library
Create T3 App has an interesting approach to this problem, creating a
Proxy
for process.env
to get the nice features I mentioned above, and more.
In my opinion if you need that extra strictness and robustness for your application, this library could be for you.
However, in most cases a simple approach as mentioned in the beginning of this post can be enough to prevent the worst bugs.
Additionally, Next.js handles .env
files out of the box and provides useful conventions such as splitting client and server environment variables simply by
prefixing the variables with NEXT_PUBLIC_
.
By adopting the T3 tool you lose these goodies and must instead adopt the library's conventions (such as splitting your schemas between client and server).
So keep that in mind.
Conclusion
Validating your environment variables at build time can be incredibly useful. With just a few lines of code you get an easy win. Beyond that, you're free to choose how much more you want to implement yourself before deciding to adopt an external tool.