process is not defined: Why One Line Works in Next.js and Throws in Vite
This codebase reads process.env 37 times, including in browser code, and never hits the error. What bundlers really do with it — and the prefix trap.
Uncaught ReferenceError: process is not defined means browser code tried to read process.env, and process is a Node.js global that does not exist in a browser. React never provided it. Whether the same line works or throws depends entirely on your bundler — this codebase reads process.env 37 times across 13 files, including in code that runs in the browser, and never sees the error.
The short answer
process is Node's global object. Browsers have no such thing. Bundlers like Next.js, Vite and Create React App simulate it by finding process.env.SOMETHING in your source at build time and replacing that text with a literal string before the code ever ships. The error appears when a bundler isn't configured to do that replacement — or when you write the access in a form the replacement can't recognise.
So the fix is never a polyfill first. It's understanding that process.env.FOO in client code is a compile-time text substitution, not a runtime object lookup.
Why the same line works here and throws in a plain Vite app
This is browser code in this repository, and it reads process.env twice:
// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
/** Browser Supabase client (anon key). Use in Client Components. */
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
}
That function is called from Client Components and runs in the browser. Paste it into a default Vite + React project and it throws process is not defined on the first call.
It works here because Next.js statically replaces any process.env.NEXT_PUBLIC_* reference in client-destined code with the value, at build time. What reaches the browser is not the code above; it is closer to:
createBrowserClient("https://your-project-ref.supabase.co", "eyJhbGci…");
There is no process object in that output, so nothing can be undefined. The same is true of Create React App with its REACT_APP_ prefix. Vite made a different choice: it exposes import.meta.env.VITE_* and deliberately does not shim process, which is why the error shows up most often in projects migrating from CRA to Vite.
| Toolchain | Client-side env access | Prefix required | process shimmed |
|---|---|---|---|
| Next.js (this project) | process.env.NEXT_PUBLIC_X | NEXT_PUBLIC_ | Inlined at build time |
| Create React App | process.env.REACT_APP_X | REACT_APP_ | Inlined at build time |
| Vite | import.meta.env.VITE_X | VITE_ | No — this is the usual cause |
| Plain webpack | Nothing by default | — | Only via DefinePlugin |
| Node / server code | process.env.X | none | Real Node global |
The prefix is a boundary, not a naming convention
Across src/ this project reads 16 distinct environment variables. Exactly three carry the public prefix:
NEXT_PUBLIC_SITE_URL 10 reads (9 of them in one test file)
NEXT_PUBLIC_SUPABASE_URL 5 reads
NEXT_PUBLIC_SUPABASE_ANON_KEY 4 reads
──────────────────────────────────────
13 others, server-only:
RESEND_API_KEY, RESEND_AUDIENCE_ID, SUPABASE_SERVICE_ROLE_KEY,
LEMONSQUEEZY_STORE_ID, LEMONSQUEEZY_STORE_SUBDOMAIN,
LEMONSQUEEZY_WEBHOOK_SECRET, LS_VARIANT_ID_T1/T2/T3,
LS_VARIANT_UUID_T1/T2/T3, NODE_ENV
The prefix is what tells the bundler "this value may be inlined into a file the public can read." That is a one-way door: a NEXT_PUBLIC_ variable is published, permanently, to everyone who loads the page. Renaming a secret to add the prefix so the error goes away is the single most damaging way to fix process is not defined.
The three public ones here are all safe to publish by design — a site origin, a project URL, and Supabase's anon key, which is meant to be public and is only useful in combination with row-level security policies. SUPABASE_SERVICE_ROLE_KEY sits on the other side of the line and bypasses RLS entirely, so this codebase backs the naming convention with a build-time guard:
// src/lib/supabase/admin.ts
import "server-only";
import { createClient as createSbClient } from "@supabase/supabase-js";
/**
* Service-role Supabase client — SERVER ONLY. Bypasses RLS. Use only in vetted
* server code (webhook writes, signed-URL issuance). Never import from a Client
* Component; the `server-only` guard makes such an import fail the build.
*/
export function createAdminClient() {
return createSbClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
}
import "server-only" is a package whose entire job is to fail the build if the module ends up in a client bundle. Without it, an accidental import from a Client Component would silently inline the service-role key into public JavaScript — and it would work, which is the worst possible outcome.
The inlining is textual, which breaks dynamic access
Because the replacement is a text substitution performed by the bundler, it only matches the literal form process.env.SOME_NAME. These do not work in client code:
// None of these can be statically replaced:
const key = "NEXT_PUBLIC_SUPABASE_URL";
process.env[key]; // ReferenceError: process is not defined
const { NEXT_PUBLIC_SITE_URL } = process.env; // same
process?.env?.NEXT_PUBLIC_SITE_URL; // same — optional chaining
// does not save you here
The optional-chaining case surprises people most. process?.env reads as defensive, but ?. only guards against process being null or undefined — it does not guard against the identifier being undeclared, which is a ReferenceError thrown before any check runs.
This codebase never writes dynamic access. Every read is the literal form, which is why the single-source-of-truth helpers are written as plain functions around a literal:
// src/lib/siteUrl.ts
export function siteUrl(fallback = "https://asoctemplates.com"): string {
return (process.env.NEXT_PUBLIC_SITE_URL || fallback).replace(/\/+$/, "");
}
Two details in that one line are load-bearing. The || rather than ?? treats an empty string as unset — an empty NEXT_PUBLIC_SITE_URL would otherwise inline as "" and produce root-relative URLs where absolute ones are required. And the trailing-slash strip is centralised here because it was previously copied to some call sites and missed at others, which put doubled slashes (https://host//dashboard) into production JSON-LD.
The failure mode that isn't a ReferenceError
A missing variable does not always announce itself. Client code gets the literal undefined inlined and carries on until something downstream complains. Server code gets a real undefined and fails wherever it's used.
This project's sharpest example runs on every single request:
// src/proxy.ts
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { … } },
);
The ! is a TypeScript non-null assertion — it silences the compiler, and it checks nothing at runtime. Deploy without those two variables set and this throws on every request, on every route, including pages that have nothing to do with authentication. The project's own CLAUDE.md carries a standing warning about exactly this, because a site-wide outage from two unset variables is a failure mode worth writing down rather than rediscovering.
The defensive pattern used where a missing value should degrade rather than explode:
// src/lib/useOwnedProducts.ts
export const COMMERCE_ENABLED = Boolean(
process.env.NEXT_PUBLIC_SUPABASE_URL &&
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
);
A boolean computed at module scope from two inlined literals. When the variables are absent this is false and the commerce UI simply doesn't render — no error, no broken control.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
process is not defined in a Vite app | Vite doesn't shim process | Use import.meta.env.VITE_X, and rename the variable with the VITE_ prefix |
process is not defined after migrating CRA → Vite | REACT_APP_* reads survived the move | Rename to VITE_* and switch to import.meta.env |
process?.env?.X still throws | ?. guards null/undefined values, not undeclared identifiers | Use the literal static form your bundler recognises |
process.env[key] is undefined in the browser | Bundlers replace literal text only; a computed key can't be matched | Write each access out literally, or build an explicit map at module scope |
Value is undefined in the browser but set in .env | Missing the public prefix, so the bundler refused to inline it | Add NEXT_PUBLIC_/VITE_/REACT_APP_ — only if the value is genuinely safe to publish |
Changed .env, browser still shows the old value | The old value was baked into the bundle at build time | Restart the dev server; redeploy to change it in production |
| A secret shows up in the browser's Sources tab | A server-only value was given a public prefix | Remove the prefix, move the read to server code, and rotate the key — it is public now |
| Site-wide 500s right after a deploy | A required server variable is unset and asserted with ! | Set it in the host's environment; ! is a compile-time assertion only |
Frequently asked questions
Should I polyfill process to fix this?
Almost never. Defining global.process = { env: {} } makes the error disappear and leaves every value undefined, converting a loud failure into a silent one. Use the env mechanism your bundler actually supports.
Is process.env available in Next.js Client Components?
Only for NEXT_PUBLIC_-prefixed variables, and only as a build-time inline — not as a real object. Unprefixed variables read as undefined in the browser, which is the intended protection, not a bug.
Why does it work in npm run dev but break in production?
Usually because the variable exists in a local .env file that was never added to the host's environment. Values are inlined at build time, so the production build is the one that has to see them.
Is the Supabase anon key safe to expose with NEXT_PUBLIC_?
Yes, by design — it identifies the project and carries no privileges of its own; row-level security policies decide what any request may read. The service-role key is the opposite: it bypasses RLS, which is why this codebase guards it with import "server-only" rather than trusting the naming convention alone.
Templates in this post
ASoc Bloom, ASoc Bumble and ASoc Circuit are Next.js + Tailwind ecommerce templates that follow the environment-variable conventions above — public values behind the NEXT_PUBLIC_ prefix, secrets read only in server code.
Browse the full sets: Next.js shop templates, Tailwind shop templates.
