Managing Environment Variables in Next.js
When our team built our first production Next.js application, we thought managing environment variables would be straightforward. After all, we had experience with Node.js apps and the standard .env files. How different could it be? As it turned out, quite different. Our journey with Next.js environment variables revealed several potential security issues and deployment challenges that weren't immediately obvious.
The Initial Setup and Unexpected Issues
For our application, we needed different configurations for development, staging, and production environments, plus some API keys and credentials. We started with a simple .env file:
# .env
API_URL=https://api.example.com
DATABASE_URL=mongodb://username:[email protected]:27017/mydb
STRIPE_SECRET_KEY=sk_test_abcdefghijklmnopqrstuvwxyz
GOOGLE_ANALYTICS_ID=UA-12345678-1
JWT_SECRET=my-super-secret-jwt-keyWe deployed the app to our staging environment, and everything seemed to work. Then we noticed something alarming in the browser's network tab – our MongoDB connection string, complete with credentials, was visible in the JavaScript bundle! Somehow, our supposedly server-side environment variables were leaking into the client-side code.
Understanding the Problem: Client vs. Server Variables
After some frantic debugging, we discovered a fundamental aspect of Next.js environment variables that wasn't clear to us at first:
In Next.js, environment variables are evaluated at build time, not runtime. By default, they're only available in the Node.js environment, but any variable prefixed with NEXT_PUBLIC_ will be inlined into the JavaScript bundle and exposed to the browser.The issue was that we had referenced some sensitive environment variables directly in components that were rendered on the client side:
// components/Dashboard.js - A client component
import { useEffect } from 'react';
export default function Dashboard() {
useEffect(() => {
// This is the problem - accessing process.env in client components
console.log('Connecting to DB at:', process.env.DATABASE_URL);
fetchData();
}, []);
async function fetchData() {
const response = await fetch(process.env.API_URL + '/data');
// ...
}
return <div>Dashboard Content</div>;
}Since Next.js statically replaces process.env.* during the build, all our environment variables were being embedded directly in the JavaScript bundle, regardless of whether they were meant to be public or private.
Solution #1: Proper NEXT_PUBLIC_ Prefixing
Our first step was to properly separate client-side and server-side variables by using the NEXT_PUBLIC_ prefix only for values that were safe to expose:
# .env
# Server-side only (not exposed to the browser)
API_URL=https://api.example.com
DATABASE_URL=mongodb://username:[email protected]:27017/mydb
STRIPE_SECRET_KEY=sk_test_abcdefghijklmnopqrstuvwxyz
JWT_SECRET=my-super-secret-jwt-key
# Client-side (exposed to the browser)
NEXT_PUBLIC_API_BASE_URL=https://api.example.com
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=UA-12345678-1Then we updated our client components to only use the public variables:
// components/Dashboard.js - Fixed version
import { useEffect } from 'react';
export default function Dashboard() {
useEffect(() => {
// No longer accessing DATABASE_URL on the client
fetchData();
}, []);
async function fetchData() {
// Using the public base URL instead
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/data`);
// ...
}
return <div>Dashboard Content</div>;
}For any operations requiring server-side variables, we moved that logic to API routes or server components where the sensitive variables remained secure:
// pages/api/data.js
import { MongoClient } from 'mongodb';
export default async function handler(req, res) {
// Safe to use DATABASE_URL here since this code only runs on the server
const client = new MongoClient(process.env.DATABASE_URL);
try {
await client.connect();
const db = client.db();
const data = await db.collection('items').find().toArray();
res.status(200).json(data);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch data' });
} finally {
await client.close();
}
}Problem #2: Environment-Specific Configurations
With our client/server separation fixed, we encountered another challenge: managing different values for different environments. We had development, staging, and production environments, each needing its own configuration.
Next.js supports environment-specific .env files with the following priority:
.env.development.local,.env.test.local,.env.production.local: Local overrides, not checked into version control.env.local: Local overrides for all environments, not checked into version control.env.development,.env.test,.env.production: Environment-specific settings.env: Default settings
We created separate files for each environment:
# .env.development
API_URL=http://localhost:3001
DATABASE_URL=mongodb://localhost:27017/mydb
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=UA-12345678-1
# .env.production
API_URL=https://api.example.com
DATABASE_URL=mongodb://username:[email protected]:27017/mydb
NEXT_PUBLIC_API_BASE_URL=https://api.example.com
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=UA-87654321-1But this led to a new issue – sensitive data like database passwords and API keys were now stored in version control. Anyone with access to our repository could see these credentials.
Solution #2: Environment Variables in Deployment
To solve this, we removed sensitive data from the checked-in .env files and instead set up our deployment process to provide these variables. First, we updated our .gitignore:
# .gitignore
# Local env files
.env*.local
# Next.js
/.next/
/out/Then, we created template files for local development:
# .env.local.example
# Copy this file to .env.local and fill in the values
API_URL=http://localhost:3001
DATABASE_URL=mongodb://localhost:27017/mydb
STRIPE_SECRET_KEY=sk_test_...
JWT_SECRET=your-local-secret-keyFor our production deployments using GitHub Actions, we stored the sensitive values as repository secrets and injected them during the build:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '16'
- name: Install dependencies
run: npm ci
- name: Build
env:
API_URL: ${{ secrets.API_URL }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
NEXT_PUBLIC_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_API_BASE_URL }}
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID: ${{ secrets.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID }}
run: npm run build
# Deployment steps...Problem #3: Runtime vs. Build-time Values
Our next challenge emerged when we tried to use dynamic values that weren't known at build time. For example, we wanted to deploy the same build to multiple environments with different configurations. We discovered that Next.js environment variables are embedded at build time, not runtime, which meant we couldn't change them after building the app.
This became a problem for our containerized deployments, where we wanted to use the same Docker image across multiple environments but with environment-specific configurations.
Solution #3: Runtime Configuration API
To solve this, we implemented a runtime configuration API. First, we created a dedicated API endpoint that would return the necessary configuration:
// pages/api/config.js
export default function handler(req, res) {
// Only return safe, public values
res.status(200).json({
apiBaseUrl: process.env.NEXT_PUBLIC_API_BASE_URL,
googleAnalyticsId: process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID,
environment: process.env.NODE_ENV,
// Add any other runtime values needed by the client
});
}Then, we created a custom hook to fetch this configuration on the client side:
// hooks/useConfig.js
import { useState, useEffect } from 'react';
export function useConfig() {
const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchConfig() {
try {
const response = await fetch('/api/config');
if (!response.ok) {
throw new Error('Failed to load configuration');
}
const data = await response.json();
setConfig(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchConfig();
}, []);
return { config, loading, error };
}Now, our client components could access the configuration using this hook:
// components/Dashboard.js
import { useConfig } from '../hooks/useConfig';
export default function Dashboard() {
const { config, loading, error } = useConfig();
if (loading) return <div>Loading configuration...</div>;
if (error) return <div>Error loading configuration: {error}</div>;
async function fetchData() {
// Using the runtime config value
const response = await fetch(`${config.apiBaseUrl}/data`);
// ...
}
return (
<div>
<p>Environment: {config.environment}</p>
<button onClick={fetchData}>Fetch Data</button>
</div>
);
}This approach allowed us to deploy the same build to different environments and control the configuration at runtime through environment variables on the server.
Problem #4: Secret Rotation and Management
As our application grew, so did the number of environment variables and secrets. We began facing issues with secret rotation and updates:
- When we rotated API keys, we needed to rebuild and redeploy the application
- Developers had to manage multiple
.env.localfiles for different projects - There was no audit trail of who had changed environment variables and when
Solution #4: Centralized Secret Management
For larger-scale applications, we moved to a more robust secret management approach using AWS Secrets Manager (though similar solutions like HashiCorp Vault or Azure Key Vault would work too). We created a server-side utility function to fetch secrets:
// lib/secrets.js
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
let secretCache = {};
let cacheTime = {};
const CACHE_TTL = 3600 * 1000; // 1 hour
export async function getSecret(secretName) {
// Check cache first
if (
secretCache[secretName] &&
Date.now() - cacheTime[secretName] < CACHE_TTL
) {
return secretCache[secretName];
}
const client = new SecretsManagerClient({
region: process.env.AWS_REGION,
});
try {
const response = await client.send(
new GetSecretValueCommand({
SecretId: secretName,
})
);
const secretValue = JSON.parse(response.SecretString);
// Update cache
secretCache[secretName] = secretValue;
cacheTime[secretName] = Date.now();
return secretValue;
} catch (error) {
console.error(`Error retrieving secret ${secretName}:`, error);
throw error;
}
}We then used this in our API routes and server-side logic:
// pages/api/data.js
import { getSecret } from '../../lib/secrets';
import { MongoClient } from 'mongodb';
export default async function handler(req, res) {
try {
// Get secrets at runtime from the secret manager
const dbSecrets = await getSecret('production/database');
const connectionString = `mongodb://${dbSecrets.username}:${dbSecrets.password}@${dbSecrets.host}:${dbSecrets.port}/${dbSecrets.dbname}`;
const client = new MongoClient(connectionString);
await client.connect();
// Rest of the handler code...
} catch (error) {
res.status(500).json({ error: 'Failed to fetch data' });
}
}This approach gave us several advantages:
- Secrets could be rotated without requiring application rebuilds
- Access to secrets could be audited and controlled at a granular level
- Different environments could use different secret values without changing code
- We could implement automatic rotation for certain types of credentials
Final Approach and Best Practices
After several iterations, we established a set of best practices for managing environment variables in Next.js:
- Prefix clarity: Use
NEXT_PUBLIC_only for variables that are explicitly safe to expose in the browser - Environment separation: Use
.env.development,.env.production, etc., for environment-specific but non-sensitive values - Local development: Use
.env.localfor local overrides, and provide example templates in version control - CI/CD integration: Inject sensitive values from CI/CD secrets during the build process
- Runtime configuration: Implement a configuration API for values that need to be determined at runtime
- Secret management: For larger applications, use a dedicated secrets management service
- Documentation: Maintain clear documentation of all environment variables, their purpose, and where they should be set
Validation and Type Safety
As a final improvement, we added validation to ensure that all required environment variables were present and correctly formatted. We created an environment validation module:
// lib/env.js
import { z } from 'zod';
// Define schema for server-side environment variables
const serverEnvSchema = z.object({
DATABASE_URL: z.string().url(),
API_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
});
// Define schema for client-side environment variables
const clientEnvSchema = z.object({
NEXT_PUBLIC_API_BASE_URL: z.string().url(),
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID: z.string().regex(/^UA-\d+-\d+$/),
});
// Function to validate server environment
export function validateServerEnv() {
try {
serverEnvSchema.parse(process.env);
return { valid: true };
} catch (error) {
console.error('❌ Invalid server environment variables:', error.format());
return { valid: false, error };
}
}
// Function to validate client environment
export function validateClientEnv() {
try {
clientEnvSchema.parse(process.env);
return { valid: true };
} catch (error) {
console.error('❌ Invalid client environment variables:', error.format());
return { valid: false, error };
}
}
// Validate on module import during development
if (process.env.NODE_ENV !== 'production') {
validateServerEnv();
validateClientEnv();
}We imported this module in our next.config.js to ensure validation happened early in the development process:
// next.config.js
if (process.env.NODE_ENV !== 'production') {
require('./lib/env');
}
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// other config...
};
module.exports = nextConfig;Conclusion
Managing environment variables in Next.js requires a thoughtful approach that accounts for the unique way Next.js handles the client/server boundary. The framework's build-time environment processing is powerful but can lead to security risks if not properly understood.
By carefully separating client and server variables, implementing proper secret management, and adding validation, we were able to create a secure and flexible configuration system for our Next.js applications. The lessons we learned have become standard practice for all our Next.js projects, helping us avoid security issues while maintaining flexibility across different deployment environments.
Remember that security is a journey, not a destination. Regularly review your environment variable handling to ensure it remains secure as your application and team evolve.