In Quantum Metropolis 2150, every system requires precise environmental configuration. Just as in the cyberpunk city every district has its own security protocols, a Next.js application needs different settings for different environments — development, staging, and production. Environment variables are the key to secure and flexible configuration management.
Environment variables are key-value pairs that configure the application's behavior without modifying the source code. Thanks to them, you can store:
Next.js has built-in support for files
.env. The framework automatically loads environment variables from several files, in a specified order of priority:1# .env files in Next.js (from the highest priority):
2.env.local # Local overrides - NEVER commit to git!
3.env.development # Only for npm run dev
4.env.production # Only for npm run build / npm start
5.env # Default values for all environmentsEach file can override values from files of lower priority. The file
.env.local always has the highest priority and should be in .gitignore.1# .env - default values (committed to repo)
2NEXT_PUBLIC_APP_NAME=QuantumCity
3NEXT_PUBLIC_API_URL=http://localhost:4000/api
4DATABASE_URL=mongodb://localhost:27017/quantum_dev
5JWT_SECRET=development-secret-key-change-in-production
6
7# .env.local - local secrets (do NOT commit!)
8DATABASE_URL=mongodb://user:secret@prod-cluster.mongodb.net/quantum
9JWT_SECRET=super-secret-production-key-2150
10OPENAI_API_KEY=sk-proj-abc123...
11STRIPE_SECRET_KEY=sk_live_abc123...
12
13# .env.production - production settings
14NEXT_PUBLIC_API_URL=https://api.quantum-city.com
15NEXT_PUBLIC_APP_NAME=QuantumCity Production
16NODE_ENV=productionThis is one of the most important rules in Next.js! The prefix
NEXT_PUBLIC_ determines whether the variable is available in the browser:1// Variables WITHOUT prefix - available ONLY on the server
2// They never make it into the browser bundle!
3const dbUrl = process.env.DATABASE_URL; // OK in Server Components
4const apiKey = process.env.OPENAI_API_KEY; // OK in Route Handlers
5const secret = process.env.JWT_SECRET; // OK in middleware
6
7// Variables WITH the NEXT_PUBLIC_ prefix - available EVERYWHERE
8// They make it into the JavaScript bundle in the browser!
9const appName = process.env.NEXT_PUBLIC_APP_NAME; // OK in Client Components
10const apiUrl = process.env.NEXT_PUBLIC_API_URL; // OK everywhere1// app/layout.tsx - Server Component
2export default function RootLayout({ children }: { children: React.ReactNode }) {
3 // Safe - server variable, does not reach the browser
4 const dbUrl = process.env.DATABASE_URL;
5 console.log('Connecting to:', dbUrl);
6
7 return (
8 <html>
9 <body>
10 {/* NEXT_PUBLIC_ - safely used in HTML */}
11 <h1>{process.env.NEXT_PUBLIC_APP_NAME}</h1>
12 {children}
13 </body>
14 </html>
15 );
16}
17
18// components/ApiStatus.tsx - Client Component
19'use client';
20import { useEffect, useState } from 'react';
21
22export default function ApiStatus() {
23 const [status, setStatus] = useState('checking...');
24
25 useEffect(() => {
26 // NEXT_PUBLIC_ variables work in the browser
27 const apiUrl = process.env.NEXT_PUBLIC_API_URL;
28
29 fetch(apiUrl + '/health')
30 .then(res => res.json())
31 .then(data => setStatus(data.status));
32 }, []);
33
34 return <span>API: {status}</span>;
35}It's important to understand when variables are read:
Variables
NEXT_PUBLIC_ are injected during npm run build. This means that after building the application, their values are "frozen" in the JavaScript code:1# These values will be injected into the bundle during build:
2NEXT_PUBLIC_API_URL=https://api.quantum-city.com npm run build
3
4# After building, changing this variable will NOT change the application's behavior!
5# You need to rebuild to apply a new value.Server variables (without
NEXT_PUBLIC_) are read in real-time — at every request:1// app/api/config/route.ts
2import { NextResponse } from 'next/server';
3
4export async function GET() {
5 // This value is read AT EVERY request
6 // Changing the variable on the server immediately affects the response
7 const dbUrl = process.env.DATABASE_URL;
8 const maxConnections = process.env.DB_MAX_CONNECTIONS || '10';
9
10 return NextResponse.json({
11 database: dbUrl ? 'connected' : 'not configured',
12 maxConnections: parseInt(maxConnections),
13 });
14}In professional applications, it's worth validating environment variables at application startup to avoid runtime errors. A popular approach is to use the Zod library:
1// lib/env.ts
2import { z } from 'zod';
3
4// Validation schema for server variables
5const serverEnvSchema = z.object({
6 DATABASE_URL: z.string().url('DATABASE_URL must be a valid URL'),
7 JWT_SECRET: z.string().min(32, 'JWT_SECRET must have at least 32 characters'),
8 OPENAI_API_KEY: z.string().startsWith('sk-', 'Invalid OpenAI key'),
9 NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
10 PORT: z.coerce.number().default(4000),
11});
12
13// Validation schema for client variables
14const clientEnvSchema = z.object({
15 NEXT_PUBLIC_APP_NAME: z.string().min(1),
16 NEXT_PUBLIC_API_URL: z.string().url(),
17});
18
19// Validation at module import
20const serverEnv = serverEnvSchema.safeParse(process.env);
21const clientEnv = clientEnvSchema.safeParse(process.env);
22
23if (!serverEnv.success) {
24 console.error('Missing server variables:', serverEnv.error.format());
25 throw new Error('Invalid server environment configuration');
26}
27
28if (!clientEnv.success) {
29 console.error('Missing client variables:', clientEnv.error.format());
30 throw new Error('Invalid client environment configuration');
31}
32
33export const env = {
34 ...serverEnv.data,
35 ...clientEnv.data,
36};1// app/api/users/route.ts
2import { env } from '@/lib/env';
3
4export async function GET() {
5 // TypeScript knows the types - env.DATABASE_URL is always a string
6 // Validation guarantees that the value is a valid URL
7 const response = await fetch(env.NEXT_PUBLIC_API_URL + '/users', {
8 headers: {
9 'Authorization': 'Bearer ' + env.JWT_SECRET,
10 },
11 });
12
13 return Response.json(await response.json());
14}The
next.config.js file can use environment variables for dynamic configuration:1// next.config.js
2/** @type {import('next').NextConfig} */
3const nextConfig = {
4 // Setting additional environment variables
5 env: {
6 CUSTOM_KEY: process.env.NODE_ENV === 'production'
7 ? 'production-value'
8 : 'development-value',
9 BUILD_TIME: new Date().toISOString(),
10 },
11
12 // Environment-dependent image configuration
13 images: {
14 remotePatterns: [
15 {
16 protocol: 'https',
17 hostname: process.env.NODE_ENV === 'production'
18 ? 'cdn.quantum-city.com'
19 : 'localhost',
20 },
21 ],
22 },
23
24 // Different headers for dev and prod
25 async headers() {
26 const securityHeaders = [
27 { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
28 { key: 'X-Content-Type-Options', value: 'nosniff' },
29 ];
30
31 if (process.env.NODE_ENV === 'production') {
32 securityHeaders.push({
33 key: 'Strict-Transport-Security',
34 value: 'max-age=63072000; includeSubDomains; preload',
35 });
36 }
37
38 return [{ source: '/:path*', headers: securityHeaders }];
39 },
40};
41
42module.exports = nextConfig;In the production environment, we never store secrets in files
.env. Instead, we use platforms for secrets management:1# Vercel - adding variables via the CLI
2vercel env add DATABASE_URL production
3vercel env add JWT_SECRET production
4vercel env add OPENAI_API_KEY production
5
6# Vercel - variables preview
7vercel env ls
8
9# Docker - variables via docker-compose.yml
10# docker-compose.yml:
11# services:
12# app:
13# environment:
14# - DATABASE_URL=${DATABASE_URL}
15# - JWT_SECRET=${JWT_SECRET}
16
17# GitHub Actions - secrets in CI/CD
18# Settings → Secrets and variables → Actions
19# Usage: ${{ secrets.DATABASE_URL }}Environment variables are the foundation of secure Next.js application configuration:
.env.local > .env.production > .envPractice these concepts in the editor below.