A module with no directive belongs to whichever graph imports it, so a shared helper is one careless import away from being compiled for the browser. Next.js 10,514 gives you two guards.
The first is automatic: only environment variables prefixed NEXT_PUBLIC_ are inlined into the client bundle, and everything else becomes an empty string, so a leaked secret turns into a broken request rather than a published credential. That is a backstop; it does not stop the code from shipping.
The second is explicit. Run npm 2,036 install server-only, then import the package for its side effect at the top of any module that must never reach the browser:
import "server-only";
export const API_KEY = process.env.API_KEY ?? "dev-key";
export function listProducts() { return [{ id: 1, name: "Keyboard" }]; }Import it from a 'use client' module and the build stops, naming the offending file and the chain that dragged it across:
Error: You're importing a module that depends on "server-only" into a React Client
Component module. This API is only available in Server Components but one of its
parents is marked with "use client", so this module is also a Client Component.
> 1 | import "server-only";
Import traces:
Client Component Browser:
./app/lib/db.js [Client Component Browser]
./app/bad-import/secrets.js [Client Component Browser]
./app/bad-import/page.js [Server Component]The import trace is the part to read: it walks from the guarded module up to the file carrying the directive, so the fix is visible. Either move the call into a Server Component and pass its serialized result down, or push 'use client' toward the leaf that needs it. client-only is the mirror image, for modules touching window or localStorage; Next.js recognizes both names internally and never runs their published contents, so installing them is optional. Not every leak is caught, though — a helper with no guard, no secret and no Node built-in crosses silently and simply makes your bundle bigger, so audit with @next/bundle-analyzer (Analyzing the Bundle).