Environment variables in Next.js
Getting environment variables wrong in Next.js can leak secrets into your client bundle, break deployments, or cause your application to crash at runtime. I've seen teams spend hours debugging mysterious failures only to discover they're trying to access a server-only variable on the client side. Understanding the distinction between server variables, public variables, build time, and runtime is the difference between a secure, maintainable application and one that's fragile from day one.
The Two Categories of Environment Variables
Next.js gives you two fundamental ways to access environment variables, and they behave completely differently. The first category is server-only variables, which never leave your server. The second is publicly exposed variables, which get baked into your client bundle at build time. Mixing these up is the most common mistake I see in production codebases.
Server Variables — Available in Server Components, Actions and Route Handlers
Server variables are the default. Any variable you define in your .env.local or .env.production file that doesn't start with NEXT_PUBLIC_ stays on the server. You can safely use them in Server Components, API route handlers, server actions, and anywhere that runs only on Node.js.
Let's say you're building a SaaS platform and you have a database password, an API key for a payment provider, and a JWT secret. These are exactly the kinds of variables you keep private.
// .env.local
DATABASE_URL=postgresql://user:password@localhost/mydb
STRIPE_SECRET_KEY=sk_live_xxxxx
JWT_SECRET=my-super-secret-key
In a Server Component, you can access them directly:
// app/dashboard/page.tsx (Server Component by default)
import { db } from '@/lib/db'
export default async function DashboardPage() {
// This works fine — we're on the server
const users = await db.query('SELECT * FROM users')
return (
<div>
<h1>Dashboard</h1>
<p>Total users: {users.length}</p>
</div>
)
}
In your API route handlers, you can also access server variables:
// app/api/checkout/route.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
const body = await request.json()
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: body.items,
mode: 'payment',
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/success`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/cancel`,
})
return Response.json({ sessionId: session.id })
}
And in server actions:
// app/actions/auth.ts
'use server'
import jwt from 'jsonwebtoken'
export async function generateToken(userId: string) {
const token = jwt.sign(
{ userId },
process.env.JWT_SECRET!,
{ expiresIn: '7d' }
)
return token
}
The key point: these variables only exist in your Node.js runtime. They're never bundled with your client JavaScript. You can feel confident using secrets here.
NEXT_PUBLIC_ Variables — Inlined into the Client Bundle at Build Time
Any variable prefixed with NEXT_PUBLIC_ gets embedded into your client bundle during the build process. This means it's readable in the browser console, in network requests, and in your source code. Use this only for non-sensitive configuration.
In a real project, you might have:
// .env.local
NEXT_PUBLIC_SITE_URL=https://myapp.com
NEXT_PUBLIC_API_BASE_URL=https://api.myapp.com
NEXT_PUBLIC_ANALYTICS_ID=UA-123456789
You can access these from Client Components:
// app/components/Footer.tsx
'use client'
export function Footer() {
return (
<footer>
<p>Visit us at {process.env.NEXT_PUBLIC_SITE_URL}</p>
<p>© {new Date().getFullYear()} My Company</p>
</footer>
)
}
You can also use them on the server, even though they're public. A common pattern is to use a public variable in an API route to construct URLs:
// app/api/send-email/route.ts
import { sendEmail } from '@/lib/email'
export async function POST(request: Request) {
const body = await request.json()
const resetUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/reset-password?token=${body.token}`
await sendEmail({
to: body.email,
subject: 'Reset your password',
body: `Click here to reset: ${resetUrl}`,
})
return Response.json({ success: true })
}
Build Time vs Runtime — Public Variables Are Frozen at Build
This is the detail that trips people up. NEXT_PUBLIC_ variables are replaced at build time, not at runtime. If you change a public variable after you've built your application, your users won't see the change until you rebuild and redeploy.
Imagine you're running a promotional campaign and you want to toggle a feature flag without redeploying. If you use NEXT_PUBLIC_FEATURE_FLAG=true, you can't just update the environment variable in your hosting platform — you need to trigger a new build. That's not fast enough for rapid experimentation.
For values that need to change at runtime without a rebuild, fetch them from an API instead:
// app/components/FeaturePromo.tsx
'use client'
import { useEffect, useState } from 'react'
export function FeaturePromo() {
const [isPromoCampaignActive, setIsPromoCampaignActive] = useState(false)
useEffect(() => {
// Fetch from API instead of using NEXT_PUBLIC_
fetch('/api/feature-flags')
.then(r => r.json())
.then(data => setIsPromoCampaignActive(data.promoCampaign))
}, [])
if (!isPromoCampaignActive) return null
return <div className="banner">Limited time offer!</div>
}
For server-side runtime values, use regular environment variables and access them in server actions or route handlers:
// app/api/config/route.ts
export async function GET() {
// This changes on each redeploy without needing a rebuild
const featureFlag = process.env.FEATURE_FLAG_PROMO === 'true'
return Response.json({ promoCampaign: featureFlag })
}
Validate Them — A Zod Schema in env.ts Fails the Build When Something Is Missing
Hard-coding process.env.SOME_VAR! throughout your codebase is brittle. If you forget to set a variable in production, you'll discover it when users complain. A better approach is to validate all environment variables at startup using a schema validator like Zod.
Create a dedicated env.ts file that's imported early in your application:
// lib/env.ts
import { z } from 'zod'
const envSchema = z.object({
// Server variables
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
JWT_SECRET: z.string().min(32),
// Public variables
NEXT_PUBLIC_SITE_URL: z.string().url(),
NEXT_PUBLIC_API_BASE_URL: z.string().url(),
})
type Environment = z.infer<typeof envSchema>
const env = envSchema.parse(process.env)
export { env }
Then import and use this at the top of your server entry point:
// app/layout.tsx
import { env } from '@/lib/env'
// If any variable is missing or invalid, the app crashes immediately with a clear error
console.log('Database URL:', env.DATABASE_URL)
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
Now your build will fail if a required variable is missing. During development, you see the error in your terminal. In production, your container fails to start before any traffic reaches it. This is much better than a vague runtime crash hours later.
Common Mistakes
Accessing server variables on the client: If you try to use a private variable in a Client Component, it will be undefined. The variable never made it into the bundle.
// app/components/BadExample.tsx
'use client'
export function BadExample() {
// This will always be undefined because JWT_SECRET is not NEXT_PUBLIC_
const secret = process.env.JWT_SECRET
// Now your code breaks because secret is undefined
const token = jwt.sign({ id: 1 }, secret)
return <div>{token}</div>
}
Expecting runtime changes to NEXT_PUBLIC_ variables: If you deploy a new environment variable value without rebuilding, client-side code won't see it. The old value is already baked in.
Committing .env.local to version control: Your .env.local file should never be in Git. Add it to .gitignore immediately. Use .env.example to document which variables your project needs.
Using process.env directly without validation: Without schema validation, typos and missing variables cause silent failures. Always validate on startup.
Key Takeaways
- Server variables (no prefix) stay private and are only available in Server Components, route handlers, and server actions.
NEXT_PUBLIC_variables are baked into the client bundle at build time — they're world-readable and immutable until the next build.- For configuration that needs to change at runtime without redeploying, fetch it from an API instead of using environment variables.
- Always validate environment variables at startup using a schema validator like Zod to catch misconfiguration early.
- Never commit
.env.localto Git. Document your variables in.env.exampleinstead.






Comments
Loading comments…