This article teaches you how to validate environment variables with Zod.
Zod is a validation library that enables you to validate your data against a pre-defined schema. The schema defines the type of your data. For instance, if you have the name
field and you want to make sure it's a string, you can create the schema as follows:
const nameSchema = z.string()
Alternatively, you can create an object schema, which is useful when you want to validate an object (a user
object in this case):
const userSchema = z.object({
name: z.string(),
});
In short, you define the schema, and Zod ensures that the data matches that schema.
One of the benefits of validating environment variables is that it helps the developers working on the project understand what values those variables accept. Another benefit is that it eliminates the issue with process.env
, where you don't know if the variable is defined or if it's misconfigured.
Client and server schema
In frameworks like Next.js, you can't access environment variables in the browser by default. They are only available in the Node.js environment. The workaround they did was to allow developers to prefix "client-side" variables with NEXT_PUBLIC_
.
As a result, we need to split the environment variables into 2 files:
- one file for the environment variables available to the browser
- one file for the other variables
Client-side validation
Create a new file clientEnvSchema.ts
, and add the following code snippet:
import z from 'zod';
const envSchema = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_HOST_NAME: z.string().url().trim().optional(),
NEXT_PUBLIC_VERCEL_URL: z.string().url().optional(),
});
export const envClientSchema = envSchema.parse({
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_HOST_NAME: process.env.NEXT_PUBLIC_HOST_NAME,
NEXT_PUBLIC_VERCEL_URL: process.env.NEXT_PUBLIC_VERCEL_URL,
});
The above code starts by defining an object schema using Zod's object
method. In this case, the environment variables being validated are:
NEXT_PUBLIC_SUPABASE_URL
- expected to be a valid URL stringNEXT_PUBLIC_HOST_NAME
- expected to be a valid URL string and is optionalNEXT_PUBLIC_VERCEL_URL
- also expected to be a valid URL string and is optional
After that, it calls the parse
method on envSchema
with an object that contains the actual values of the environment variables. The variables are fetched from process.env
one by one. Otherwise, it throws an error.
The parse
method then validates that the actual environment variables match the format specified in envSchema
. If any validation fails, it will throw an error.
Lastly, the parsed and validated environment variables are exported as envClientSchema
for usage in the client-side code. You can use them in your code as follows envClientSchema.NEXT_PUBLIC_VERCEL_URL
.
Server-side validation
The server environment variables are validated similarly. Create a new file serverEnvSchema.ts
and add the following code:
import z from 'zod';
const envSchema = z.object({
PREVIEW_API_KEY: z.string().trim().min(1),
PORT: z.number().default(3000),
NODE_ENV: z
.enum(['development', 'production', 'test'])
.default('development'),
DATABASE_URL: z.string().trim().min(1),
});
export const envServerSchema = envSchema.parse(process.env);
In this case, you can pass the process.env
object to the parse
method without destructuring it. Now you can use the environment variables envServerSchema.NODE_ENV
in the server code.
Taking it a step further
Zod throws an error automatically when there is a problem with the environment variables, but it's not legible.
So, let's modify the code in the serverEnvSchema.ts
file:
import z from 'zod';
const envSchema = z.object({
PREVIEW_API_KEY: z.string().trim().min(1),
STRIPE_SECRET_KEY: z.string().trim().min(1),
STRIPE_SIGNING_SECRET: z.string().trim().min(1),
USER_INSERTED_TOKEN: z.string().trim().min(1),
LOOPS_API_URL: z.string().url(),
LOOPS_TOKEN: z.string().trim().min(1),
PORT: z.number().default(3000),
NODE_ENV: z
.enum(['development', 'production', 'test'])
.default('development'),
DATABASE_URL: z.string().trim().min(1),
});
const envServer = envSchema.safeParse({
PREVIEW_API_KEY: process.env.PREVIEW_API_KEY,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_SIGNING_SECRET: process.env.STRIPE_SIGNING_SECRET,
USER_INSERTED_TOKEN: process.env.USER_INSERTED_TOKEN,
LOOPS_API_URL: process.env.LOOPS_API_URL,
LOOPS_TOKEN: process.env.LOOPS_TOKEN,
PORT: process.env.PORT,
NODE_ENV: process.env.NODE_ENV,
DATABASE_URL: process.env.DATABASE_URL,
});
if (!envServer.success) {
console.error(envServer.error.issues);
throw new Error('There is an error with the server environment variables');
process.exit(1);
}
export const envServerSchema = envServer.data;
safeParse
is a Zod method that returns an object that contains a success
property and a data
property if the parsing is successful. Or a success
property and an error
property if the parsing fails.
If there are one or more errors, it displays them in the console and then exits the Node.js application.
Extend the ProcessEnv
interface
One trick I've found recently from Matt Pocock is to extend the ProcessEnv
interface.
type EnvSchemaType = z.infer<typeof envSchema>;
declare global {
namespace NodeJS {
interface ProcessEnv extends EnvSchemaType {}
}
}
The above code creates a TypeScript type EnvSchemaType
that matches the types defined in the envSchema
. It then uses the EnvSchemaType
to extend the ProcessEnv
interface from the global NodeJS namespace with the type inferred from the envSchema
.
As a result, the environment variables from the envSchema
are correctly typed and available on the process.env
. Even better, you have them on autocomplete.
The end
You now have strongly typed environment variables and auto-complete. If your environment variables are correctly set, your app runs as usual. Otherwise, Zod throws an error and stops the app from running.
Other Zod-related articles: