Every application has values that differ between a laptop, staging and production: database URLs, API keys, the public site address. Next.js loads them from .env files with clear precedence and draws a hard line between server secrets and values exposed to the browser. Alongside that sits next.config.ts. After this lesson you will be able to organise environment files, expose variables safely, validate them at startup, and use the most common configuration options.
Next.js loads variables from the project root in this order; earlier entries win:
| File | Loaded when | Commit to git? |
|---|---|---|
| process.env (shell, CI, hosting dashboard) | Always | n/a |
| .env.development.local / .env.production.local | Matching NODE_ENV | No |
| .env.local | Every environment except test | No |
| .env.development / .env.production / .env.test | Matching NODE_ENV | Yes |
| .env | Always | Yes |
next dev sets NODE_ENV to development; next build and next start set it to production. Keep defaults in .env, personal overrides and secrets in .env.local, and let the hosting platform inject production values.
# .env
NEXT_PUBLIC_SITE_URL=http://localhost:3000
LOG_LEVEL=info
# .env.local (ignored by git)
DATABASE_URL=postgresql://app:secret@localhost:5432/app
SESSION_SECRET=replace-with-a-long-random-stringBy default a variable is available only in server code: Server Components, Route Handlers, Server Actions, the proxy and next.config.ts. It is never bundled for the browser. To expose a value to client code, prefix it with NEXT_PUBLIC_:
"use client";
export function Analytics() {
// Inlined at build time into the client bundle
const key = process.env.NEXT_PUBLIC_ANALYTICS_KEY;
return <script data-key={key} />;
}Inlining has two consequences: the value is fixed when next build runs, so changing it requires a rebuild, and it is visible to anyone who opens the developer tools. Server-side variables are read at request time and can be rotated without a rebuild.
A missing variable that surfaces as undefined deep in a request is painful to debug. Validate once with Zod, as in the sample at the top of this lesson, and import env instead of process.env everywhere. The application refuses to start with a clear message if anything is missing, and every consumer gets typed values. Add import "server-only" to the file so it can never reach a client bundle.
next.config.ts is typed and supports the options below, among many others:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
output: "standalone", // self-contained build for Docker
images: {
remotePatterns: [new URL("https://cdn.example.com/**")],
},
async redirects() {
return [{ source: "/old-blog/:slug", destination: "/blog/:slug", permanent: true }];
},
async headers() {
return [{ source: "/(.*)", headers: [{ key: "X-Content-Type-Options", value: "nosniff" }] }];
},
serverExternalPackages: ["mongoose"], // keep native modules out of the bundler
};
export default nextConfig;| Option | Purpose |
|---|---|
| images.remotePatterns | Allow-list of hosts next/image may optimise |
| redirects() / rewrites() | Static routing rules without code in the proxy |
| headers() | Security and caching headers per path pattern |
| output | "standalone" for containers, "export" for static hosting |
| typedRoutes | Type-checked href values in Link |
Changes to next.config.ts require restarting the dev server.
.env.local; add it to .gitignore (the create-next-app template already does).process.env.X in a client component without the prefix, which silently yields undefined.Which prefix exposes an environment variable to browser code?
.env holds shared defaults, .env.local holds secrets, and hosting injects production values.NEXT_PUBLIC_ variables are inlined at build time and public.env object everywhere.next.config.ts configures images, redirects, headers, output mode and more, fully typed.Next lesson: Database Access with Prisma, Drizzle and Mongoose ā connect Server Components and actions to a real database.