Environment and Security
Your API key just showed up in a GitHub security alert. Someone scraped your client-side JavaScript bundle and found DATABASE_URL hardcoded in the code. This happens when developers accidentally expose server secrets to the browser. Next.js provides multiple layers of protection against this, but you need to know how to use them.
Outcome
Create a Data Access Layer (a centralized module that handles all database queries and data retrieval) protected by server-only that causes build failures when accidentally imported into Client Components.
Fast Track
- Install
server-onlypackage and createlib/server/db.tswith the import - Try importing it in a Client Component and observe the build error
- Create proper Data Transfer Objects (DTOs) that safely pass data to clients
Environment Variable Precedence
Next.js loads environment variables from multiple files in a specific order:
1. process.env (runtime environment)
2. .env.$(NODE_ENV).local (.env.development.local, .env.production.local) — NODE_ENV is the standard environment variable indicating whether you're in development, production, or test mode
3. .env.local (not loaded in test environment)
4. .env.$(NODE_ENV) (.env.development, .env.production)
5. .env (base defaults)
The first match wins. This means .env.development.local overrides .env.local, which overrides .env.development, which overrides .env.
If you use a /src directory, .env.* files still go in the project root, not inside /src.
The NEXT_PUBLIC_ Boundary
Variables prefixed with NEXT_PUBLIC_ are inlined into the JavaScript bundle at build time:
# Safe: Only accessible server-side
DATABASE_URL="postgresql://user:password@localhost/db"
# EXPOSED: Embedded in client JavaScript bundle
NEXT_PUBLIC_ANALYTICS_ID="G-ABC123"After build, NEXT_PUBLIC_ANALYTICS_ID becomes a hardcoded string in your JavaScript. Anyone can read it via browser DevTools.
NEXT_PUBLIC_* variables are replaced at build time with static values. Changing them requires a rebuild. Dynamic lookups like process.env[varName] will not be inlined.
Self-Paced Exercise
Requirements:
- Install the
server-onlypackage - Create a Data Access Layer in
lib/server/withserver-onlyprotection - Create a function that returns safe, minimal data (a DTO)
- Demonstrate the build error when
server-onlycode is imported into a Client Component - Show the correct pattern: Server Component calls DAL, passes DTO to Client Component
Implementation hints:
- The
server-onlypackage causes a build-time error if imported in client code - A Data Access Layer centralizes data fetching and authorization in one place
- Data Transfer Objects (DTOs) contain only the fields needed for rendering
- Server Components can safely call the DAL and pass sanitized data to Client Components
Try It
Step 1: Install server-only
pnpm add server-only --filter @repo/webStep 2: Create a protected Data Access Layer
import "server-only";
// Simulate a database call that uses server secrets
export function getUserFromDB(userId: string) {
// In real code, this would use process.env.DATABASE_URL
// The INTERNAL_CONFIG demonstrates server-only variable access
const config = process.env.INTERNAL_CONFIG ?? "default";
// Simulated database response with sensitive fields
return {
id: userId,
email: "user@example.com",
passwordHash: "bcrypt$2b$10$...", // NEVER expose this
internalNotes: `VIP customer (config: ${config})`, // NEVER expose this
name: "Jane Developer",
createdAt: new Date().toISOString(),
};
}Step 3: Create a safe DTO function
import "server-only";
import { getUserFromDB } from "./db";
// Return only safe, public fields
export function getUserDTO(userId: string) {
const user = getUserFromDB(userId);
// Only return fields that are safe to expose
return {
id: user.id,
name: user.name,
createdAt: user.createdAt,
};
}Step 4: Use the DTO in a Server Component
import { getUserDTO } from "@/lib/server/user-dto";
import { UserCard } from "@/components/user-card";
export default function SecurityDemoPage() {
// Server Component safely calls the Data Access Layer
const user = getUserDTO("user-123");
return (
<main className="flex flex-col gap-4 p-4">
<h1 className="font-bold text-2xl">Security Demo</h1>
<p className="text-gray-600">
This page demonstrates secure data fetching patterns.
</p>
{/* Pass only the safe DTO to the Client Component */}
<UserCard user={user} />
</main>
);
}Step 5: Create a Client Component that receives safe data
"use client";
type UserDTO = {
id: string;
name: string;
createdAt: string;
};
export function UserCard({ user }: { user: UserDTO }) {
return (
<div className="rounded border bg-white p-4 shadow-sm">
<h2 className="font-semibold">{user.name}</h2>
<p className="text-gray-500 text-sm">ID: {user.id}</p>
<p className="text-gray-500 text-sm">
Joined: {new Date(user.createdAt).toLocaleDateString()}
</p>
</div>
);
}Step 6: Verify the server-only protection
Create a test component that incorrectly imports server code:
"use client";
// This will cause a BUILD ERROR when used
import { getUserFromDB } from "@/lib/server/db";
export function BadImport() {
const _user = getUserFromDB("test");
return <div>This should never render</div>;
}Create a page that uses this component (unused files are tree-shaken):
import { BadImport } from "@/components/bad-import";
export default function TestBadImportPage() {
return <BadImport />;
}Run the build:
pnpm build --filter @repo/webExpected error output:
Error: Turbopack build failed with 2 errors:
./apps/web/src/lib/server/db.ts:1:1
Ecmascript file had an error
> 1 | import "server-only";
| ^^^^^^^^^^^^^^^^^^^^
'server-only' cannot be imported from a Client Component module.
It should only be used from a Server Component.
The server-only package catches the mistake at build time, not in production. Delete both test files after verifying the error.
Step 7: Clean up and verify the page works
rm apps/web/src/components/bad-import.tsx
rm -r apps/web/src/app/test-bad-import
pnpm dev --filter @repo/webVisit http://localhost:3000/security-demo and verify the UserCard displays safe data.
Environment File Documentation
Your .env.local was created in Project Setup via vercel link. For team documentation, create a .env.example that shows required variables without real values:
# Client-accessible (bundled in JavaScript at build time)
# Only use for truly public data like analytics IDs
NEXT_PUBLIC_APP_NAME="Your App Name"
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
# Server-only (undefined in Client Components)
# Safe for secrets - never prefix these with NEXT_PUBLIC_
INTERNAL_CONFIG="your-server-config"
DATABASE_URL="postgresql://user:password@localhost/db"Commit .env.example to version control. It documents required variables without exposing real values. New team members can copy it to .env.local and fill in their own values, or use vercel env pull to get values from the project.
Commit
git add -A
git commit -m "feat(foundation): add server-only Data Access Layer for security"Done-When
server-onlypackage is installed (pnpm list server-only --filter @repo/webshows it)lib/server/db.tsexists withimport "server-only"at the top- Build fails when a page imports a Client Component that uses
lib/server/db.ts /security-demopage displays user data through the safe DTO pattern.env.exampledocuments both public and server-only variables
Troubleshooting
Module not found: server-only
Module not found: Can't resolve 'server-only'Fix: Install the package in the correct workspace:
pnpm add server-only --filter @repo/webBuild error persists after removing bad import
Fix: Clear the Next.js cache:
rm -rf apps/web/.next
pnpm build --filter @repo/webAsk your coding agent for help. Paste the error message and it can diagnose the issue.
My environment variable is `undefined` in Next.js 16.
**Variable name:** `_____`
Example: `DATABASE_URL` or `NEXT_PUBLIC_API_URL`
**Where I'm accessing it:**
- [ ] Server Component
- [ ] Client Component
- [ ] API Route / Route Handler
- [ ] Server Action
- [ ] next.config.js
**My .env file location:** _____
Example: `apps/web/.env.local` or `.env.local` at root
**My .env file contents (redact sensitive values):**
```
___PASTE_RELEVANT_ENV_LINES___
```
Example:
```
DATABASE_URL=postgres://...
NEXT_PUBLIC_API_URL=https://api.example.com
```
**How I'm accessing it:**
```tsx
___PASTE_CODE_ACCESSING_ENV_VAR___
```
Example:
```tsx
const url = process.env.DATABASE_URL
// or
const apiUrl = process.env.NEXT_PUBLIC_API_URL
```
**Questions:**
1. Does my variable need the `NEXT_PUBLIC_` prefix?
2. Is my .env file in the right location for my monorepo?
3. Did I restart the dev server after adding the variable?
Why is my environment variable undefined and how do I fix it?Solution
Complete Implementation
lib/server/db.ts
import "server-only";
export function getUserFromDB(userId: string) {
const config = process.env.INTERNAL_CONFIG ?? "default";
return {
id: userId,
email: "user@example.com",
passwordHash: "bcrypt$2b$10$...",
internalNotes: `VIP customer (config: ${config})`,
name: "Jane Developer",
createdAt: new Date().toISOString(),
};
}lib/server/user-dto.ts
import "server-only";
import { getUserFromDB } from "./db";
export function getUserDTO(userId: string) {
const user = getUserFromDB(userId);
return {
id: user.id,
name: user.name,
createdAt: user.createdAt,
};
}app/security-demo/page.tsx
import { getUserDTO } from "@/lib/server/user-dto";
import { UserCard } from "@/components/user-card";
export default function SecurityDemoPage() {
const user = getUserDTO("user-123");
return (
<main className="flex flex-col gap-4 p-4">
<h1 className="font-bold text-2xl">Security Demo</h1>
<p className="text-gray-600">
This page demonstrates secure data fetching patterns.
</p>
<UserCard user={user} />
</main>
);
}components/user-card.tsx
"use client";
type UserDTO = {
id: string;
name: string;
createdAt: string;
};
export function UserCard({ user }: { user: UserDTO }) {
return (
<div className="rounded border bg-white p-4 shadow-sm">
<h2 className="font-semibold">{user.name}</h2>
<p className="text-gray-500 text-sm">ID: {user.id}</p>
<p className="text-gray-500 text-sm">
Joined: {new Date(user.createdAt).toLocaleDateString()}
</p>
</div>
);
}Advanced: Defense in Depth
Layer 1: Build-Time Protection (server-only)
The server-only package is your first line of defense. It catches mistakes during development before they reach production.
Layer 2: Data Minimization (DTOs)
Never pass entire database objects to Client Components. Create explicit DTO functions that return only what the UI needs. This follows the principle of API minimization (only exposing the minimum data necessary for a given purpose).
Layer 3: Environment Variable Hygiene
- NEVER prefix secrets with
NEXT_PUBLIC_ - Use
.env.localfor local secrets (gitignored by default) - Use
.env.exampleto document required variables (committed) - Consider using a secrets manager (services that securely store and manage sensitive credentials) for production (Vercel, AWS Secrets Manager, etc.)
Layer 4: Data Tainting (Experimental)
React provides experimental APIs to "taint" objects and values, preventing them from being passed to the client:
import { experimental_taintObjectReference } from "react";
const user = await db.query("SELECT * FROM users WHERE id = ?", [userId]);
experimental_taintObjectReference("Do not pass user to client", user);To enable tainting in Next.js:
module.exports = {
experimental: {
taint: true,
},
};Tainting is experimental and subject to change. Use server-only as your primary protection mechanism.
References
- Environment Variables - Loading and bundling env vars
- Data Security - Protecting sensitive data in Server Components
- server-only package - Build-time protection for server code
Was this helpful?