Vercel Logo

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

  1. Install server-only package and create lib/server/db.ts with the import
  2. Try importing it in a Client Component and observe the build error
  3. 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.

Where to Put .env Files

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:

.env.local
# 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.

Build-Time Substitution

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:

  1. Install the server-only package
  2. Create a Data Access Layer in lib/server/ with server-only protection
  3. Create a function that returns safe, minimal data (a DTO)
  4. Demonstrate the build error when server-only code is imported into a Client Component
  5. Show the correct pattern: Server Component calls DAL, passes DTO to Client Component

Implementation hints:

  • The server-only package 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/web

Step 2: Create a protected Data Access Layer

apps/web/src/lib/server/db.ts
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

apps/web/src/lib/server/user-dto.ts
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

apps/web/src/app/security-demo/page.tsx
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

apps/web/src/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>
  );
}

Step 6: Verify the server-only protection

Create a test component that incorrectly imports server code:

apps/web/src/components/bad-import.tsx
"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):

apps/web/src/app/test-bad-import/page.tsx
import { BadImport } from "@/components/bad-import";
 
export default function TestBadImportPage() {
  return <BadImport />;
}

Run the build:

pnpm build --filter @repo/web

Expected 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 Protection Works

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/web

Visit 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:

apps/web/.env.example
# 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"
Best Practice

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-only package is installed (pnpm list server-only --filter @repo/web shows it)
  • lib/server/db.ts exists with import "server-only" at the top
  • Build fails when a page imports a Client Component that uses lib/server/db.ts
  • /security-demo page displays user data through the safe DTO pattern
  • .env.example documents 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/web
Build error persists after removing bad import

Fix: Clear the Next.js cache:

rm -rf apps/web/.next
pnpm build --filter @repo/web
Still Stuck?

Ask your coding agent for help. Paste the error message and it can diagnose the issue.

Prompt: Debug Undefined Environment Variable
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

apps/web/src/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

apps/web/src/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

apps/web/src/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

apps/web/src/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.local for local secrets (gitignored by default)
  • Use .env.example to 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:

next.config.js
module.exports = {
  experimental: {
    taint: true,
  },
};
Experimental Feature

Tainting is experimental and subject to change. Use server-only as your primary protection mechanism.

References