Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 083 / 100

NEXT_PUBLIC_ means public for React & Next.js developers

Anything with that prefix is visible to every visitor.

Expona Admin8 min read

I've spent enough time debugging production incidents to know that leaking API keys, database credentials, or payment gateway secrets through environment variables is one of the fastest ways to turn a good day into a nightmare. Today I want to talk about a fundamental security principle in Next.js that trips up even experienced developers: NEXT_PUBLIC_ means public. If you prefix a variable with NEXT_PUBLIC_, it gets baked into your client-side JavaScript bundle and is visible to every single visitor who inspects your network requests or views your page source.

The fundamental rule: NEXT_PUBLIC_ variables end up in the browser

When you define an environment variable in Next.js with the NEXT_PUBLIC_ prefix, it's not just available on the server—it gets embedded directly into your JavaScript bundle that runs in the browser. This happens at build time. There's no runtime magic that hides it. Every visitor to your site can extract that value in seconds using their browser's developer tools or by examining your bundled JavaScript files.

I learned this lesson early in my career when a junior developer committed a database password prefixed with NEXT_PUBLIC_ to our repository. Within hours, someone had dumped our entire development database. The fix was straightforward: understand the distinction between server-side and client-side variables. Variables without the prefix only exist on the server. Variables with the prefix get exposed to the browser. It's that simple.

Here's a practical scenario: you're building an e-commerce platform and you need to integrate with a payment processor like Stripe. Stripe provides two keys—a publishable key (safe to expose) and a secret key (never expose). Your instinct might be to do this:

tsx

// .env.local
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xyz123abc
STRIPE_SECRET_KEY=sk_live_xyz123abc

This is correct. The publishable key can be public; the secret key must never have the NEXT_PUBLIC_ prefix. If you reversed these, anyone with access to your bundle could charge customers or refund themselves money.

Never put secrets in NEXT_PUBLIC_ variables

Secrets include any credential, API key, or token that grants access to protected resources. This category is broader than you might initially think. It includes:

  • Database connection strings and passwords
  • API keys for third-party services (OpenAI, AWS, etc.)
  • OAuth secrets and refresh tokens
  • Payment processor secret keys
  • JWT signing keys
  • Private encryption keys
  • Internal service authentication tokens

All of these should live in environment variables that do not have the NEXT_PUBLIC_ prefix, which means they only exist on the server side and never get bundled with client code.

Let me show you a real example from a CRM system I built. We integrated with an email service that required an API key for sending transactional emails. Here's what I initially wrote:

tsx

// pages/api/send-email.ts
import { sendEmail } from '@/lib/email';

export default async function handler(req, res) {
  const { to, subject, body } = req.body;
  
  try {
    await sendEmail({
      to,
      subject,
      body,
      apiKey: process.env.NEXT_PUBLIC_EMAIL_API_KEY, // WRONG
    });
    res.status(200).json({ success: true });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
}

I caught myself before committing this. The email API key should only be accessed on the server, never on the client. Here's the corrected version:

tsx

// pages/api/send-email.ts
import { sendEmail } from '@/lib/email';

export default async function handler(req, res) {
  const { to, subject, body } = req.body;
  
  try {
    await sendEmail({
      to,
      subject,
      body,
      apiKey: process.env.EMAIL_API_KEY, // Correct: no prefix means server-only
    });
    res.status(200).json({ success: true });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
}

The email API key is now truly secret because it's never exposed to the browser. If a user wanted to call your API endpoint to send an email, they'd still need proper authentication (a valid user session, for example), but they'd never see the actual API key.

Only publishable keys and public URLs belong in NEXT_PUBLIC_

So what should you put in NEXT_PUBLIC_ variables? Anything that's intentionally public and doesn't grant access to sensitive resources.

This includes:

  • Publishable API keys (Stripe, Mapbox, etc.)
  • Public service URLs or API endpoints
  • Analytics tracking IDs
  • Feature flags and feature toggle values
  • Public configuration values
  • CDN URLs

Here's a realistic configuration from a ride-hailing platform I worked on:

tsx

// .env.local
NEXT_PUBLIC_MAPBOX_TOKEN=pk_live_xyz
NEXT_PUBLIC_API_BASE_URL=https://api.example.com
NEXT_PUBLIC_ANALYTICS_ID=GA-123456789
NEXT_PUBLIC_FEATURE_ENABLE_NEW_BOOKING_UI=true

DATABASE_URL=postgresql://user:pass@db.example.com:5432/rides
NEXTAUTH_SECRET=abcdef123456
STRIPE_SECRET_KEY=sk_live_xyz
TWILIO_AUTH_TOKEN=xyz

The NEXT_PUBLIC_ variables here are all safe to expose. Mapbox's publishable token is designed to be used from the browser. The API base URL is something anyone can see by inspecting network requests. Analytics IDs are intentionally public. Feature flags are meant to be used client-side. None of these grant access to protected resources.

The unprefixed variables are server-only secrets. They're used in API routes, server-side functions, and during the build process, but never sent to the browser.

Search your bundle for leaked keys

If you're concerned that you might have already leaked a secret, you can inspect your built bundle to see what variables are actually included. Next.js outputs your bundle to the .next directory after build.

Here's a practical way to search for accidental leaks:

tsx

// From your project root, after building:
// Unzip and search the bundle
unzip .next/server/chunks/*.js | grep -i "sk_live" | grep -v node_modules

// Or use a more targeted approach:
strings .next/server/app/layout.js | grep -E "(sk_|secret|password)" | head -20

A better approach is to use automated tooling. Tools like git-secrets or detect-secrets can scan your repository for common secret patterns before they get committed. Here's a simple script you can add to your project:

tsx

// scripts/check-bundle.js
const fs = require('fs');
const path = require('path');

const secretPatterns = [
  /sk_live_[a-zA-Z0-9]{24}/g, // Stripe secret
  /password\s*[:=]\s*['"][^'"]+['"]/gi,
  /api[_-]?key\s*[:=]\s*['"][^'"]+['"]/gi,
];

function checkBundle() {
  const bundlePath = path.join(__dirname, '../.next');
  
  function searchDir(dir) {
    const files = fs.readdirSync(dir);
    files.forEach(file => {
      const fullPath = path.join(dir, file);
      const stat = fs.statSync(fullPath);
      
      if (stat.isDirectory()) {
        searchDir(fullPath);
      } else if (file.endsWith('.js')) {
        const content = fs.readFileSync(fullPath, 'utf-8');
        secretPatterns.forEach(pattern => {
          const matches = content.match(pattern);
          if (matches) {
            console.error(`⚠️  Potential secret found in ${fullPath}`);
            matches.forEach(m => console.error(`   ${m}`));
          }
        });
      }
    });
  }
  
  searchDir(bundlePath);
}

checkBundle();

Run this after every build to catch leaks before they reach production. You can integrate it into your CI/CD pipeline to fail the build if secrets are detected.

Rotate immediately if one leaks

If you discover that a secret has been exposed through your bundle, your application, or your repository, there's no time to waste. Here's the protocol:

  1. Identify what leaked. Which specific key, token, or credential was exposed? Was it a database password, API key, OAuth token?
  2. Revoke it immediately. Log into the service that issued the credential and revoke it right away. Most services have a dashboard where you can disable an old key and generate a new one.
  3. Generate a replacement. Create a new credential with a fresh value.
  4. Update your systems. Replace the old value with the new one in your environment variables, secrets management system, and deploy immediately.
  5. Monitor for abuse. Check logs and usage patterns to see if the leaked credential was used by an attacker. For database credentials, scan for unauthorized access. For payment processors, check for unauthorized transactions.
  6. Communicate with your team. Let your team know what happened so they can be vigilant for related incidents.

I once had a team member accidentally commit a Stripe secret key to a public GitHub repository. We caught it within minutes because of CI/CD hooks, but we didn't take any chances. We revoked that key immediately, generated a new one, and updated production within 15 minutes. We also went through commit history to verify it was the only leak. It's a manual process, but it beats dealing with fraudulent charges.

Common mistakes

  • Confusing server and client. Thinking that because you're using an API route, you can safely use NEXT_PUBLIC_ variables. Remember: NEXT_PUBLIC_ variables are visible to the browser regardless of where you import them. The real protection is that sensitive logic runs server-side, not client-side.
  • Assuming the backend is secure enough. Even if your backend validates every request, exposing an API key in the client bundle means anyone can call your backend endpoints while impersonating your application. This bypasses rate limits, authentication checks, and audit trails.
  • Using wildcards in .gitignore for env files but forgetting .env.local. Make sure all local environment files are ignored. Your .gitignore should include .env.local, .env.*.local, and similar patterns.
  • Hardcoding values as fallbacks. I've seen developers do this: const apiKey = process.env.NEXT_PUBLIC_API_KEY || 'default-key-xyz'. Never hardcode secrets, even as fallbacks.
  • Trusting that renamed variables are secret. Calling something PRIVATE_API_KEY doesn't make it private. Only the NEXT_PUBLIC_ prefix matters. Everything else gets bundled.

Key takeaways

  • NEXT_PUBLIC_ variables are baked into your client-side JavaScript bundle at build time and are visible to every visitor.
  • Never prefix secrets (database passwords, private API keys, OAuth secrets) with NEXT_PUBLIC_.
  • Only use NEXT_PUBLIC_ for publishable keys, public URLs, feature flags, and other intentionally public configuration.
  • Variables without the prefix only exist on the server and are safe for secrets.
  • After building, inspect your bundle to catch accidental leaks before production.
  • If a secret does leak, revoke it immediately and rotate to a new value.
  • Use automated tooling in your CI/CD pipeline to detect secrets before they're committed or deployed.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles