Skip to main content
ASoc
Tutorial

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.

The ASoc Team9 min read

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.

ToolchainClient-side env accessPrefix requiredprocess shimmed
Next.js (this project)process.env.NEXT_PUBLIC_XNEXT_PUBLIC_Inlined at build time
Create React Appprocess.env.REACT_APP_XREACT_APP_Inlined at build time
Viteimport.meta.env.VITE_XVITE_No — this is the usual cause
Plain webpackNothing by defaultOnly via DefinePlugin
Node / server codeprocess.env.XnoneReal 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

SymptomCauseFix
process is not defined in a Vite appVite doesn't shim processUse import.meta.env.VITE_X, and rename the variable with the VITE_ prefix
process is not defined after migrating CRA → ViteREACT_APP_* reads survived the moveRename to VITE_* and switch to import.meta.env
process?.env?.X still throws?. guards null/undefined values, not undeclared identifiersUse the literal static form your bundler recognises
process.env[key] is undefined in the browserBundlers replace literal text only; a computed key can't be matchedWrite each access out literally, or build an explicit map at module scope
Value is undefined in the browser but set in .envMissing the public prefix, so the bundler refused to inline itAdd NEXT_PUBLIC_/VITE_/REACT_APP_ — only if the value is genuinely safe to publish
Changed .env, browser still shows the old valueThe old value was baked into the bundle at build timeRestart the dev server; redeploy to change it in production
A secret shows up in the browser's Sources tabA server-only value was given a public prefixRemove the prefix, move the read to server code, and rotate the key — it is public now
Site-wide 500s right after a deployA 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.

Keep reading

Tutorial9 min read

React Protected Routes: What a Server-Rendered Guard Does Differently

React Router's client-side wrapper isn't the only pattern. This codebase's redirect guard runs on the server, plus the open-redirect check most tutorials skip on the ?next= param.

Read more
Tutorial10 min read

A React Sidebar in 60 Lines and Zero JavaScript

Sticky positioning without a scroll listener, aria-current for the active row, and the hard-coded active-state bug that only appears when you add a second page.

Read more