Environment Variables in Next.js

2026-06-21·Ctrotech·
guidenextjs

Environment Variables in Next.js

Next.js runs code on both server and client. Environment variables need to be split accordingly. The @ctroenv/nextjs adapter handles this automatically.

The Problem

Next.js bundles your code for the browser. Any env var referenced in client code gets inlined at build time. If you accidentally reference DATABASE_URL in a client component, it's baked into the JavaScript bundle — visible to anyone who views the page source.

Next.js enforces a convention: client-facing variables must be prefixed with NEXT_PUBLIC_. CtroEnv's adapter enforces this at the type level.

Server/Client Schema

Split your schema into server and client blocks:

import { string, number, type ClientServerSchema } from "@ctroenv/core"
import { defineEnv } from "@ctroenv/nextjs"

const schema = {
  server: {
    DATABASE_URL: string().url(),
    JWT_SECRET: string().min(32).secret(),
    REDIS_URL: string().url().optional(),
  },
  client: {
    NEXT_PUBLIC_API_URL: string().url(),
    NEXT_PUBLIC_APP_NAME: string().min(1),
  },
} satisfies ClientServerSchema

const env = defineEnv(schema)

Server components access everything:

// Server Component
env.DATABASE_URL            // ✅ string
env.JWT_SECRET              // ✅ string
env.NEXT_PUBLIC_API_URL     // ✅ string

Client components can only access NEXT_PUBLIC_ variables:

// Client Component
env.NEXT_PUBLIC_API_URL     // ✅ string
env.DATABASE_URL            // ❌ Throws: "Server-only env var..."

Accessing Secrets Server-Side

Server secrets are masked in the proxy. Use meta.get() for raw values:

// In API route or server action
const jwt = env.meta.get("JWT_SECRET")
const token = sign({ userId }, jwt)

meta works the same as the core package — non-enumerable, excluded from JSON output.

Build-Time Validation

Wrap your Next.js config with withCtroEnv() to validate at build time:

// next.config.ts
import { withCtroEnv } from "@ctroenv/nextjs"
import type { NextConfig } from "next"

const nextConfig: NextConfig = {
  // your existing config
}

export default withCtroEnv(schema, nextConfig)

If DATABASE_URL is missing or JWT_SECRET is too short, the build fails immediately with a clear error. No waiting for a deployment to crash.

With App Router

The adapter works with both Pages Router and App Router. Create a shared env module:

// lib/env.ts
import { defineEnv } from "@ctroenv/nextjs"
import { schema } from "./schema"

export const env = defineEnv(schema)

Import env from this module in any server component, route handler, or server action. The Proxy handles server/client enforcement at runtime.

TypeScript Inference

The inferred type merges both server and client schemas:

env.DATABASE_URL            // string
env.JWT_SECRET              // string
env.NEXT_PUBLIC_API_URL     // string
env.NEXT_PUBLIC_APP_NAME    // string

No | undefined for required vars. No type assertions needed.

Migration from process.env

Replace:

const dbUrl = process.env.DATABASE_URL!

With:

const dbUrl = env.DATABASE_URL

If DATABASE_URL is missing, you get a typed error at startup instead of a runtime crash in a random API route.

Comparison with Next.js Built-in

FeatureNext.js process.envCtroEnv adapter
Server/client enforcementManual (NEXT_PUBLIC_ convention)Automatic (Proxy throws on server-only access)
TypeScript typesstring | undefinedExact inferred types
Startup validationNonewithCtroEnv() validates on build
Secret maskingNoneProxy masks .secret() values
Cross-package schemasNot supportedextendSchema() for monorepos