A Note to Myself on Next.js Environment Variables
I have written code like this more times than I want to admit:
const url = `https://api.example.com/?key=${process.env.API_KEY}`;
If API_KEY is missing, TypeScript won’t stop the app from starting. The request goes out with undefined, and the error may show up much later as an unhelpful Unauthorized response.
This is a note to myself: check required configuration at startup, while the error can still tell me exactly what’s missing.
Validate, Then Add Types
Austin Shelby’s article, “The correct way to load environment variables in Next.js”, uses Zod to describe the variables an app requires. The same idea works with any runtime schema validator:
import { z } from "zod";
const environmentSchema = z.object({
API_KEY: z.string().min(1),
NEXT_PUBLIC_MESSAGE: z.string().min(1),
});
export const environment = environmentSchema.parse(process.env);
Parsing does the runtime check. If a required value is missing or empty, Zod throws an error right there instead of letting the application fail later in an unrelated request.
The schema can also give us the TypeScript type for process.env. Infer the type from the schema, then add it to the type Node already uses for environment variables:
type EnvironmentVariables = z.infer<typeof environmentSchema>;
declare global {
namespace NodeJS {
interface ProcessEnv extends EnvironmentVariables {}
}
}
That block looks odd at first, so here is what each part does.
z.infer reads the schema and produces a plain type. For our schema, EnvironmentVariables is { API_KEY: string; NEXT_PUBLIC_MESSAGE: string }. We never write that type by hand, so it can’t drift from the schema.
declare global is needed because this file has an import, which makes it a module. Anything declared inside a module stays local to that module. Wrapping the declaration in declare global tells TypeScript to apply it to the global scope instead, where Node’s own types live.
namespace NodeJS and interface ProcessEnv name the exact type we want to change. Node’s type definitions (@types/node) declare process.env as NodeJS.ProcessEnv, which is roughly:
declare global {
namespace NodeJS {
interface ProcessEnv {
[key: string]: string | undefined;
TZ?: string;
}
}
}
Every key is string | undefined, which is why process.env.API_KEY normally needs a check before we can use it as a string.
TypeScript lets an interface be declared more than once. Declarations with the same name in the same scope merge into one interface. This is called declaration merging. Our declaration does not replace Node’s ProcessEnv. It adds to it. By extending EnvironmentVariables, the merged interface now also says API_KEY and NEXT_PUBLIC_MESSAGE are string. The interface body is empty because extends already brings in every property.
The result: in the editor, process.env.API_KEY autocompletes and has the type string, not string | undefined.
This only affects types. It does not load .env files or check that the values exist. TypeScript trusts the declaration, so the type is only true because the schema check above runs when the app starts. Put this declaration in a file that your tsconfig.json includes, or TypeScript won’t see it.
Loading a Local .env File
Next.js loads environment files for the application automatically. A separate validation script, though, runs outside the normal Next.js app startup. If it needs to read .env.local, use the @next/env package:
import { loadEnvConfig } from "@next/env";
import { z } from "zod";
loadEnvConfig(process.cwd());
const environmentSchema = z.object({
API_KEY: z.string().min(1),
NEXT_PUBLIC_MESSAGE: z.string().min(1),
});
environmentSchema.parse(process.env);
Then run the check before starting the app or building it. The exact command depends on how the project runs TypeScript; Austin’s example uses Node’s type stripping and requires a recent Node version. If the project uses another TypeScript runner, use that instead.
{
"scripts": {
"dev": "node --experimental-strip-types scripts/validate-env.ts && next dev",
"build": "node --experimental-strip-types scripts/validate-env.ts && next build"
}
}
That makes the check explicit. Check your package manager’s lifecycle behavior before relying on predev or prebuild hooks.
Source
- Austin Shelby, “The correct way to load environment variables in Next.js”. The validation pattern and motivation in this post are based on Austin’s article; the notes about public variables and script behavior are included to clarify how I would apply the pattern in a project.