Using Environment Variables in Vercel Edge Middleware

This guide is part of Environment Variables and Secrets at the Edge. It answers one question precisely: when your middleware writes process.env.SOMETHING, what actually happens, and at which point in the pipeline is the value decided.

Vercel’s edge runtime exposes process.env even though it is not Node, which is convenient and misleading in equal measure. Some of those reads are resolved by the compiler before your code ever ships; others are resolved by the platform on each invocation. The difference determines whether rotating a value requires a redeploy, whether a preview deployment can reach production data, and whether a credential ends up in a JavaScript file served to browsers.

The problem

Three symptoms, one confusion. First: you rotate an API key in the Vercel dashboard, the middleware keeps sending the old one, and nothing in the logs explains why. Second: a variable reads fine in a route handler and comes back undefined in middleware.ts. Third — the expensive one — a security scan finds your analytics write key sitting in plain text inside a file under .next/static/chunks/, publicly fetchable, because at some point someone renamed it with a NEXT_PUBLIC_ prefix to make an import work.

All three follow from the same misunderstanding: treating process.env as a single runtime lookup table when it is really two different mechanisms wearing one name.

Root cause: the NEXT_PUBLIC_ prefix is a publication declaration, not a convenience

Next.js performs a static find-and-replace at build time. Any expression of the exact form process.env.NEXT_PUBLIC_FOO in code that can reach the client bundle is textually substituted with the string literal of that variable’s value during compilation. The identifier disappears; only the value remains in the emitted JavaScript. That is why the prefix is not a naming convention or a hint — it is an instruction to the compiler that reads, in effect, “copy this value into files that will be served to the public.”

Variables without the prefix are handled differently. Server-side code, including middleware, receives them from the runtime environment at invocation time. Vercel injects the environment for the target deployment into the isolate, so process.env.UPSTREAM_TOKEN is a live read: change the value, redeploy or not depending on the case, and the isolate observes the new one.

The wrinkle that produces the second symptom is that this substitution only happens for statically analysable expressions. Write process.env.NEXT_PUBLIC_API_BASE and the compiler sees it. Write process.env[key] with a computed key, or destructure const { NEXT_PUBLIC_API_BASE } = process.env, and there is nothing to substitute — the expression survives into the bundle and evaluates against an object that does not contain the key. It comes back undefined, with no build error.

What the compiler does to each read Source code on the left contains one prefixed and one unprefixed environment read. After compilation the prefixed read has become a hard-coded string literal in the client bundle, while the unprefixed read remains a runtime lookup resolved in the isolate. Source After build process.env.NEXT_PUBLIC_API_BASE declared publishable process.env.UPSTREAM_TOKEN no prefix, server only "https://api.example.com" literal, inside .next/static process.env.UPSTREAM_TOKEN resolved per invocation inline keep The red path is irreversible: once built, the value is a string in a file anyone can download.
Adding the prefix does not expose a variable to more code — it copies the value into artifacts served to the public.

Step 1 — Classify every variable before you name it

Naming is the decision. Once a variable is prefixed and a build has shipped, the value has been published and must be treated as public regardless of what you rename it to afterwards. Write the classification down in one module so it is reviewable.

// lib/env.ts
/**
 * Publishable values. These are inlined into the client bundle at build time.
 * Anything listed here is, by definition, public. Never add a credential.
 */
export const publicEnv = {
  apiBase: process.env.NEXT_PUBLIC_API_BASE ?? "https://api.example.com",
  buildSha: process.env.NEXT_PUBLIC_BUILD_SHA ?? "dev",
} as const;

/**
 * Server-only values. Read at invocation time inside the edge isolate.
 * Referencing any of these from a client component is a build error.
 */
export interface ServerEnv {
  UPSTREAM_TOKEN: string;
  SESSION_SECRET: string;
  FEATURE_RING: "canary" | "stable";
  VERCEL_ENV: "development" | "preview" | "production";
}

export function readServerEnv(): ServerEnv {
  const required = ["UPSTREAM_TOKEN", "SESSION_SECRET"] as const;
  const missing = required.filter((k) => !process.env[k]);
  if (missing.length > 0) {
    // Names only. Never interpolate a value into an error or a log line.
    throw new Error(`Missing server environment variables: ${missing.join(", ")}`);
  }
  return {
    UPSTREAM_TOKEN: process.env.UPSTREAM_TOKEN as string,
    SESSION_SECRET: process.env.SESSION_SECRET as string,
    FEATURE_RING: (process.env.FEATURE_RING as ServerEnv["FEATURE_RING"]) ?? "stable",
    VERCEL_ENV: (process.env.VERCEL_ENV as ServerEnv["VERCEL_ENV"]) ?? "development",
  };
}

Note the deliberate asymmetry: publicEnv uses static member expressions so the compiler can substitute them, while readServerEnv is free to use computed access because nothing there is inlined. Reversing that — computed access on a prefixed name — is exactly the mistake that produces undefined in production.

Step 2 — Read them in middleware

Middleware runs in the edge runtime on every matched request. Read server variables through the helper rather than scattering process.env reads through the file, so the missing-variable check runs in one place.

// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { readServerEnv } from "./lib/env";

export default async function middleware(request: NextRequest): Promise<Response> {
  const env = readServerEnv();
  const url = request.nextUrl;

  // Preview deployments must never talk to production upstreams by accident.
  if (env.VERCEL_ENV === "preview" && url.pathname.startsWith("/api/billing")) {
    return new NextResponse(null, { status: 404 });
  }

  const headers = new Headers(request.headers);
  // Remove anything a client may have supplied under our own header names.
  headers.delete("x-upstream-auth");
  headers.delete("x-feature-ring");
  headers.set("x-upstream-auth", `Bearer ${env.UPSTREAM_TOKEN}`);
  headers.set("x-feature-ring", env.FEATURE_RING);

  console.log(JSON.stringify({
    event: "mw.forward",
    env: env.VERCEL_ENV,
    ring: env.FEATURE_RING,
    // Shape, never substance.
    tokenLength: env.UPSTREAM_TOKEN.length,
    path: url.pathname,
  }));

  return NextResponse.next({ request: { headers } });
}

VERCEL_ENV is supplied by the platform on every deployment and is the cheapest guard you have against a preview deployment reaching something it should not. It is distinct from NODE_ENV, which is production for any optimized build including previews and therefore useless for this purpose.

Step 3 — Scope the matcher

Every matched request pays for middleware execution, and every unmatched one costs nothing. Exclude static assets and anything with no environment-dependent behaviour.

// middleware.ts (continued)
export const config = {
  runtime: "edge",
  matcher: [
    // Everything except framework assets, images and public health checks.
    "/((?!_next/static|_next/image|favicon.ico|robots.txt|api/health).*)",
  ],
};

Excluding _next/static matters for a second reason here: those are precisely the files that carry inlined public values, and running credential-bearing middleware over them is pure waste. The reasoning behind matcher scoping generally is covered in controlling matcher order in Next.js middleware.

Step 4 — Set per-environment values

Vercel scopes each variable to one or more of development, preview and production. The same name can hold three different values, and the deployment picks up whichever set matches its target. Set them from the CLI so the configuration is scriptable and reviewable rather than clicked into a dashboard.

# Production only. Prompts for the value; it stays out of shell history.
vercel env add UPSTREAM_TOKEN production

# A distinct, lower-privilege credential for every preview deployment.
vercel env add UPSTREAM_TOKEN preview

# Pull the development set into a git-ignored .env.local for `next dev`.
vercel env pull .env.local --environment=development

# Names and targets only; values are masked in the listing.
vercel env ls

Give previews their own credential pointing at a sandbox upstream. Preview URLs are shared in pull requests, deployed from branches that have not been reviewed, and sometimes indexed; a preview holding the production token turns every open branch into a route to production data.

When each value is decided A sequence across build, deploy and request lanes shows the prefixed variable frozen during compilation while the server variable is injected into the isolate at invocation. A dashboard change after the build reaches only the server variable. build deploy request NEXT_PUBLIC_ value frozen into chunks env set attached by target: preview / prod server var read live, per invocation dashboard change after build reaches only the green box
Rotation latency is a property of where a value was resolved: server variables change on the next request, inlined ones only on the next build.

Step 5 — Understand why a rotated public value needs a redeploy

This follows directly from the inlining. When you change NEXT_PUBLIC_API_BASE in the dashboard, you have changed an input to the build, not an input to the running application. The deployed chunks still contain the literal string produced by the previous compilation, and nothing about the deployment will notice. Serving the new value requires producing new chunks — that is, a redeploy.

Server-side variables behave the way people expect: the isolate reads them at invocation, so a change plus a redeploy of the same commit propagates without recompiling anything meaningful. In practice you still redeploy, because Vercel binds the environment set to a deployment, but the mental model differs: for server variables the redeploy re-binds, for public ones it re-compiles.

The practical consequence is that anything you expect to rotate on short notice — a rate limit, a kill switch, an upstream host during an incident — must not be prefixed, even if the client would find it convenient. Push those to the server side and let middleware apply them, or move them out of environment variables entirely and into a KV read, as described in caching API responses in Cloudflare KV for the equivalent pattern.

Step 6 — Gate the build on a leak check

The only reliable defence against an accidental prefix is a mechanical one: grep the built client output for the values you know must never appear there, and fail the build if any is found. Run it after next build and before deploy.

#!/usr/bin/env bash
# scripts/check-static-leaks.sh — run after `next build`, before deploy.
set -uo pipefail

STATIC_DIR=".next/static"
SECRET_VARS=(UPSTREAM_TOKEN SESSION_SECRET DATABASE_URL STRIPE_SECRET_KEY)
status=0

if [ ! -d "$STATIC_DIR" ]; then
  printf 'no %s directory; run next build first\n' "$STATIC_DIR" >&2
  exit 1
fi

for name in "${SECRET_VARS[@]}"; do
  value="${!name-}"
  # An unset variable in this build target is not a leak; skip it.
  [ -z "$value" ] && continue
  # Guard against short or placeholder values matching everything.
  if [ "${#value}" -lt 12 ]; then
    printf 'refusing to scan for short value of %s\n' "$name" >&2
    status=1
    continue
  fi
  if grep -rqF -- "$value" "$STATIC_DIR"; then
    # Report the variable name and the file, never the value itself.
    printf 'LEAK: value of %s found in client bundle:\n' "$name" >&2
    grep -rlF -- "$value" "$STATIC_DIR" >&2
    status=1
  fi
done

# Second gate: no variable name suggesting a credential may carry the prefix.
if env | grep -qE '^NEXT_PUBLIC_[A-Z_]*(SECRET|TOKEN|KEY|PASSWORD)'; then
  printf 'LEAK: a credential-shaped name carries the NEXT_PUBLIC_ prefix\n' >&2
  env | grep -oE '^NEXT_PUBLIC_[A-Z_]*(SECRET|TOKEN|KEY|PASSWORD)[A-Z_]*' >&2
  status=1
fi

exit "$status"

The second gate catches the case the first cannot: a variable that has been prefixed but whose value has not yet been referenced anywhere, so it is not in the bundle today and will be the moment someone imports it. Catching the name is cheaper than catching the leak.

Environment comparison matrix A three-column matrix compares local development, preview and production deployments across where variables come from, what rotating one costs, and which upstream credential each should hold. development preview production source .env.local (pulled) preview env set production env set rotate public var restart dev server rebuild branch rebuild and promote rotate server var edit file redeploy, no recompile redeploy, no recompile upstream credential local sandbox shared sandbox production, scoped Preview URLs are shared in pull requests; treat their credentials as semi-public.
The bottom row is the one auditors ask about: a preview holding the production credential makes every open branch a path to real data.

Local versus production divergence

Behaviour next dev locally Vercel edge deployment
Variable source .env.local, .env.development Environment set bound to the deployment target
VERCEL_ENV Unset unless pulled; defaults to development preview or production, always present
NODE_ENV development production for previews too — not a target signal
Public var change Picked up on dev-server restart Requires a new build
Server var change Picked up on dev-server restart Requires a redeploy to re-bind
Missing variable Throws on first request you exercise Throws on every matched request
Bundle inspection .next/static present locally Same paths, served publicly

The NODE_ENV row is worth internalising. Because optimized preview builds report production, any guard written as if (process.env.NODE_ENV === "production") fires on previews too. Use VERCEL_ENV whenever you mean “the real deployment.”

Validating with Vitest

Environment reads are notoriously untested because they feel like configuration rather than logic. They are logic: the classification helper decides what gets published and what fails closed.

// lib/env.test.ts
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { readServerEnv } from "./env";
import middleware from "../middleware";

const original = { ...process.env };

function setEnv(vars: Record<string, string | undefined>) {
  for (const [k, v] of Object.entries(vars)) {
    if (v === undefined) delete process.env[k];
    else process.env[k] = v;
  }
}

describe("environment handling", () => {
  beforeEach(() => {
    setEnv({
      UPSTREAM_TOKEN: "tok_live_abcdefghijklmnop",
      SESSION_SECRET: "sess_secret_abcdefghijkl",
      FEATURE_RING: "canary",
      VERCEL_ENV: "production",
    });
  });

  afterEach(() => {
    process.env = { ...original };
    vi.restoreAllMocks();
  });

  it("throws naming the missing variables and nothing else", () => {
    setEnv({ UPSTREAM_TOKEN: undefined });
    expect(() => readServerEnv()).toThrow(/UPSTREAM_TOKEN/);
    expect(() => readServerEnv()).not.toThrow(/sess_secret/);
  });

  it("defaults FEATURE_RING to stable when unset", () => {
    setEnv({ FEATURE_RING: undefined });
    expect(readServerEnv().FEATURE_RING).toBe("stable");
  });

  it("forwards the upstream credential as a header", async () => {
    const res = await middleware(
      new Request("https://app.test/dashboard") as never,
    );
    const forwarded = res.headers.get("x-middleware-request-x-upstream-auth")
      ?? res.headers.get("x-upstream-auth");
    expect(forwarded).toContain("tok_live_abcdefghijklmnop");
  });

  it("overwrites a client-supplied ring header rather than trusting it", async () => {
    const spoofed = new Request("https://app.test/dashboard", {
      headers: { "x-feature-ring": "internal" },
    });
    const res = await middleware(spoofed as never);
    const ring = res.headers.get("x-middleware-request-x-feature-ring")
      ?? res.headers.get("x-feature-ring");
    expect(ring).toBe("canary");
  });

  it("blocks billing routes on preview deployments", async () => {
    setEnv({ VERCEL_ENV: "preview" });
    const res = await middleware(
      new Request("https://branch.test/api/billing/invoices") as never,
    );
    expect(res.status).toBe(404);
  });

  it("keeps credential-shaped names out of the publishable set", async () => {
    const publishable = Object.keys(process.env).filter((k) => k.startsWith("NEXT_PUBLIC_"));
    for (const name of publishable) {
      expect(name).not.toMatch(/SECRET|TOKEN|KEY|PASSWORD/);
    }
  });
});

The last test is a cheap unit-level mirror of the CI grep. It will not catch a value leaked under an innocuous name, which is why both exist, but it fails in a developer’s editor within a second of the mistake being made rather than minutes later in a pipeline.

Common pitfalls and resolutions

A prefixed variable is undefined at runtime — the read was computed or destructured, so the compiler had nothing to substitute. Use the literal member expression process.env.NEXT_PUBLIC_NAME exactly as written.

A rotated public value keeps serving the old string — it was inlined at build time. Redeploy to recompile; there is no runtime path that will pick it up.

A guard fires on preview deployments — the check reads NODE_ENV, which is production for any optimized build. Switch to VERCEL_ENV.

A credential appears in .next/static — a NEXT_PUBLIC_ prefix was added to a server-only variable. Rotate the credential immediately; removing the prefix does not un-publish an already-deployed build.

Preview deployments mutate production data — the preview environment holds the production credential. Give previews their own, pointed at a sandbox upstream.

Middleware throws on every request after a config change — a required variable was removed from one target only. readServerEnv names it in the error; check vercel env ls for the target in question.

A value works in a route handler but not in middleware — middleware runs in the edge runtime with a restricted environment surface. Confirm the variable is set for the deployment target rather than only for the development set pulled locally.

Production deployment checklist

Frequently Asked Questions

What does the NEXT_PUBLIC_ prefix actually do?

It instructs the compiler to replace the read with a string literal of the value during the build, so the value is embedded in JavaScript files served to browsers. It is a publication declaration, not a convenience or a naming convention, and anything carrying it must be treated as public.

Why is my prefixed variable undefined even though it is set?

Because the substitution only applies to statically analysable expressions. Destructuring from process dot env or using computed key access leaves nothing for the compiler to replace, so the expression survives into the bundle and evaluates against an object that does not contain the key. Write the literal member expression instead.

Why does rotating a public value require a redeploy?

Because the old value was inlined into the emitted chunks at build time. Changing the variable changes an input to the build, not an input to the running application, so the deployed files still contain the previous literal. Only a new build produces chunks carrying the new value.

Should I use NODE_ENV or VERCEL_ENV to detect production?

Use VERCEL_ENV. NODE_ENV reports production for any optimized build, including preview deployments, so a guard written against it fires on branches too. VERCEL_ENV distinguishes development, preview and production and is supplied by the platform on every deployment.

Can preview deployments share production environment variables?

They can, and they should not. Preview URLs are shared in pull requests and built from unreviewed branches, so a preview holding the production credential turns every open branch into a path to real data. Give previews their own lower-privilege values pointed at a sandbox upstream.

How do I prove no secret reached the client bundle?

Run a grep over the built static output for the exact values of your known secret variables and fail the pipeline on any match, then separately reject any variable name containing SECRET, TOKEN, KEY or PASSWORD that carries the public prefix. The first catches leaked values, the second catches names that will leak on the next import.