Next.js

Fix: $VARIABLE is not listed as a dependency in turbo.json

Declare the variable once, in env or globalEnv, and both the lint error and the silent runtime drop under Strict Mode disappear.

Mahdi Ben RhoumaMahdi Ben Rhouma
12 min read

TL;DR#

turbo/no-undeclared-env-vars, a rule shipped in eslint-plugin-turbo, fails every process.env.X that is not accounted for in the turbo.json entries that cover that file — globalEnv, a task's own env, or an automatic framework prefix such as NEXT_PUBLIC_*. It only ever fires where ESLint itself runs: a dedicated lint task, CI, an editor extension, or — on a Next.js setup where linting is still wired into the build — next build itself. Add the exact name to globalEnv or to the task that reads it. Do not copy the shape of the top-voted Stack Overflow answer for this exact error without checking it first — joining two variable names inside one array string declares neither of them.

text
Error: $NEXT_STRIPE_SK is not listed as a dependency in turbo.json  turbo/no-undeclared-env-vars
Error: $NEXT_PUBLIC_STRIPE_PK is not listed as a dependency in turbo.json  turbo/no-undeclared-env-vars

This is the exact pair of errors from the Stack Overflow question this error comes from: two environment variables, one of them already prefixed NEXT_PUBLIC_, both rejected by the same rule on the same npm run build.

Read the question's own turbo.json before assuming the fix below is missing from it: both names already appear three times over, in globalEnv, in a task named order#build's own env, and $-prefixed inside globalDependencies. The thread never explains what still produced these two errors against that configuration. One real gap in it, reproducible rather than guessed: copied character for character, the question's turbo.json is not valid JSON — as pasted, it closes with },, a comma after the file's final closing brace, which JSON.parse rejects outright. Linted against that exact broken text in a fresh node:22-alpine container with eslint-plugin-turbo 2.11.10, the rule reproduces the exact two error lines quoted in the question; removing only that trailing comma, it flags neither. That does not prove what the asker's real file on disk contained, and it does not test — so it cannot explain — why the other three declared variables were not flagged too; that selectivity remains open. If a reader's own configuration already declares the variable the way this one does, the workspace-scope, comma and editor-cache mechanisms covered below are where to look next, not the plain "add it to globalEnv" fix.

Why it happens#

turbo/no-undeclared-env-vars is static analysis, not something turbo run itself prints — it is an ESLint rule, and it only ever fires while ESLint is linting a file. Its own documentation is direct about the reason it exists: it "ensures that all detectable usage of environment variables are correctly included in cache keys," so that "build outputs remain correctly cacheable across environments" (rule docs). In plain terms, Turborepo hashes a task's inputs — including the environment variables declared for that task — to decide whether it can replay a cached result instead of re-running the command. A variable nobody declared cannot be part of that hash, so two builds that differ only by a secret key could wrongly share a cached output. The lint rule exists to catch the missing declaration before that happens, at lint time rather than at a shared cache.

Reading the rule's own source (not just its docs) settles exactly what counts as "declared": no-undeclared-env-vars.ts calls into calculate-inputs.ts, which accepts a variable through any of these paths:

  • it is in the root turbo.json's top-level globalEnv;
  • or in the env array of a task defined at the root with no workspace prefix (a plain build or lint entry) — that kind of task applies to every linted file regardless of which workspace it lives in, because the rule has no notion of "this file belongs to the build task" when the task itself is not scoped to one workspace;
  • or, for a file that belongs to one workspace, in the env of that workspace's own task — either a "workspaceName#task" entry in the root turbo.json, or a task defined directly in that workspace's own turbo.json. This path does not reach other workspaces' files;
  • or in passThroughEnv / globalPassThroughEnv — variables Turborepo lets through to the process without folding them into the cache hash;
  • or it carries a leading $ inside the legacy dependsOn / globalDependencies arrays, the convention turbo.json used before it had dedicated env and globalEnv keys;
  • or it is a key inside a file listed under a task's dotEnv, or the root's globalDotEnv — older keys read by dotenv-processing.ts, which parses each listed file and collects its variable names. The official migrate-dot-env codemod moves a task's dotEnv into inputs for Turborepo 2.0, and the current schema no longer documents either key, but the rule's own Project class still reads them when present.

A further path needs no turbo.json entry at all. Framework Inference adds an implicit prefix allowance per package, based on that package's own package.json dependencies: NEXT_PUBLIC_* for a package that depends on next, VITE_* for Vite, REACT_APP_* for Create React App, and several others. The Stack Overflow thread does not show which package the failing file lives in or that package's own package.json, so it is not possible to tell from the question alone whether this exemption should have applied to NEXT_PUBLIC_STRIPE_PK there — step 3 below is how to check it on a real project today.

The exact wording also depends on whether any workspace in the project has its own turbo.json. The same source builds three different messages for the same rule: plain "… is not listed as a dependency in turbo.json" when no workspace anywhere has its own turbo.json file — true even in a monorepo, as long as every task lives in one root turbo.json with workspace-scoped names like order#build, which is the shape the question's own turbo.json shows, though the thread does not say whether any of its workspaces also had their own turbo.json; "… in root turbo.json" once some other workspace has its own turbo.json but the linted file's workspace does not; and "… in the root turbo.json or workspace (<path>) turbo.json" when the linted file's own workspace has one too. A reader in a monorepo who searches the exact error and finds nothing should drop "root" or "workspace" from the search, not assume a different rule is involved.

This whole mechanism is reproducible outside the asker's own repository. In a throwaway node:22-alpine container with eslint-plugin-turbo 2.11.10 and ESLint 9.39.5 (25 October 2026), a file reading an undeclared variable fails exactly this way:

text
/work/index.js
  1:25  error  MY_SECRET_TOKEN is not listed as a dependency in turbo.json  turbo/no-undeclared-env-vars
 
✖ 1 problem (1 error, 0 warnings)

With next in that same package's own package.json, linting a second file that reads process.env.NEXT_PUBLIC_STRIPE_PK next to a genuinely undeclared variable used as a control flags only the control:

text
/work/next-var.js
  2:15  error  TOTALLY_UNDECLARED_VAR is not listed as a dependency in turbo.json  turbo/no-undeclared-env-vars

Removing next from that package.json and re-linting the same file turns the Framework Inference exemption off for that one package — the previously-exempt variable starts failing too:

text
/work/next-var.js
  1:12  error  NEXT_PUBLIC_STRIPE_PK is not listed as a dependency in turbo.json   turbo/no-undeclared-env-vars
  2:15  error  TOTALLY_UNDECLARED_VAR is not listed as a dependency in turbo.json  turbo/no-undeclared-env-vars

What this run shows directly is that the next entry in package.json is what switches the exemption on and off; because root and package were the same single package.json here, it cannot by itself show whether that is specifically the linted file's own package rather than any package in the monorepo. For that, the docs and the source are the evidence: Framework Inference is per-package, and frameworkEnvMatches resolves it by walking up from the linted file to the nearest package.json, not to the monorepo root's.

Fix#

  1. Add the exact variable name to globalEnv. This is the fastest fix and the right one for a variable several tasks read. Define tasks under the current schema key, tasks, not the pipeline key turbo.json used before Turborepo 2.0: reproduced directly, eslint-plugin-turbo 2.11.10 still reads a pipeline-keyed file correctly for this one rule, but that is this plugin's own backward compatibility, not a claim about what turbo run itself does with one — for turbo itself, migrate with the official @turbo/codemod rename-pipeline, which turns every pipeline key into tasks:

    turbo.json
    json
    {
      "$schema": "https://turborepo.dev/schema.json",
      "globalEnv": [
        "NEXT_PUBLIC_SUPABASE_URL",
        "NEXT_PUBLIC_SUPABASE_ANON_KEY",
        "SUPABASE_SERVICE_ROLE",
        "NEXT_STRIPE_SK",
        "NEXT_PUBLIC_STRIPE_PK"
      ],
      "tasks": {
        "build": {
          "dependsOn": ["^build"],
          "outputs": ["dist/**", ".next/**"]
        }
      }
    }
  2. Prefer a task's own env array when only that task reads the variable. For a value that actually reaches turbo as a process environment variable — the shell, CI, a platform — a globalEnv entry changes the cache hash of every task the moment that value changes; a task-scoped env entry only changes that task's hash, so an unrelated lint or test task keeps its cache:

    turbo.json
    json
    {
      "tasks": {
        "build": {
          "env": ["NEXT_STRIPE_SK", "NEXT_PUBLIC_STRIPE_PK"],
          "outputs": [".next/**"]
        },
        "lint": {
          "outputs": []
        }
      }
    }

    Neither env nor globalEnv does that for a value that only ever lives in a .env file — turbo never loads the file, so a changed value inside it never reaches the hash through either key. Turborepo's own guidance is to list the file itself under inputs instead, so the task misses cache when its contents change:

    turbo.json
    json
    {
      "tasks": {
        "build": {
          "env": ["NEXT_STRIPE_SK", "NEXT_PUBLIC_STRIPE_PK"],
          "inputs": ["$TURBO_DEFAULT$", ".env.local"],
          "outputs": [".next/**"]
        }
      }
    }

    inputs only fixes that caching gap; it does nothing for this lint rule. calculate-inputs.ts builds its declared-variable list from globalEnv / env, globalPassThroughEnv / passThroughEnv, the legacy $-prefixed dependsOn / globalDependencies, and the dotEnv keys above — never from inputs — so the variable itself still needs one of those, or a framework prefix, for the error to go away.

  3. If the variable already carries a recognised framework prefix and still fails, the package that reads it is missing the dependency Framework Inference keys on. Check the package.json of the package containing the file being linted — not the monorepo root — for next (for NEXT_PUBLIC_*) in its dependencies, devDependencies or peerDependencies; the rule reads all three. Other frameworks key on their own package name the same way — vite for VITE_*, react-scripts for REACT_APP_* — per the full list the rule imports.

Verify the fix#

Re-run the linter against the same file. A clean pass prints nothing and exits 0:

bash
npx eslint --no-color index.js; echo "EXIT:$?"
text
EXIT:0

The top-voted answer tells readers who still see the error after this to restart the editor's ESLint server (VS Code: Command Palette → "ESLint: Restart ESLint Server"). The rule's own source explains when that restart is actually needed and when it is not, because it keeps two different kinds of cache: the parsed turbo.json is re-stat'ed by modified time roughly once a second and reloaded on the next lint automatically, with nothing to restart; but the per-package framework and dependency lookups (frameworkEnvCache, packageJsonDepCache) are cached once per process for good, with no revalidation at all. Editing turbo.json (steps 1-2) is picked up on the next lint either way. Editing a package.json's dependencies (step 3) is not — not inside the same long-running editor ESLint process, which is exactly when the restart the top-voted answer suggests is the real fix. A one-shot CLI run is unaffected either way, because it starts both caches empty.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

The comma mistake in the top-voted answer#

The question has no accepted answer, but its top-voted answer (11 net votes) tells readers to add both variables at once, written as a single string:

turbo.json (the shape of the top-voted fix)
json
{
  "$schema": "https://turborepo.dev/schema.json",
  "globalEnv": ["NEXT_STRIPE_SK, NEXT_PUBLIC_STRIPE_PK"],
  "tasks": {
    "build": {
      "outputs": [".next/**"]
    }
  }
}

That one array entry is a single string, "NEXT_STRIPE_SK, NEXT_PUBLIC_STRIPE_PK" — comma, space and all — not two variable names. The plugin's own wildcard matcher (wildcard-processing.ts) treats every character except * literally and anchors the whole entry with ^(...)$, so this pattern only ever matches a variable that is itself named NEXT_STRIPE_SK, NEXT_PUBLIC_STRIPE_PK — nothing a real process.env lookup will ever ask for. Reproduced directly: declaring ["MY_SECRET_TOKEN, OTHER_VAR"] this way and linting a file that reads both variables leaves both flagged, unchanged from having no declaration at all —

text
/work/two-vars.js
  1:11  error  MY_SECRET_TOKEN is not listed as a dependency in turbo.json  turbo/no-undeclared-env-vars
  2:11  error  OTHER_VAR is not listed as a dependency in turbo.json        turbo/no-undeclared-env-vars

— while the same two names as two separate array entries, ["MY_SECRET_TOKEN", "OTHER_VAR"], clear both with no error at all. The only difference between the two is two quotation marks — closing the first name and opening the second, exactly where the comma already sits — and that is the entire gap between a fix that works and one that silently does not.

A third, lowest-voted answer in the same thread suggests switching to bracket notation, process.env["KEY"], so that "turbo.json should leave you alone." The rule's own MemberExpression handler shows that only works when the key comes from a variable the rule cannot resolve at lint time. A literal bracket access, process.env["NEXT_STRIPE_SK"], hits the same checkKey call as the dot-notation form and is flagged identically; only process.env[someVariableHoldingTheName] escapes, because the rule has no static value to check. Rewriting process.env.NEXT_STRIPE_SK as process.env["NEXT_STRIPE_SK"] changes nothing.

Declaring it is not the same as keeping it at runtime#

Two different mechanisms answer two different questions, and conflating them causes a second, quieter bug once the lint error is gone. The ESLint rule only asks whether turbo.json knows a variable exists, for hashing purposes. Strict Mode asks a harder question at turbo run time: is this variable allowed to reach the task's process at all? Strict Mode has been the default since Turborepo 2.0, replacing the Loose Mode that let every environment variable through unfiltered in 1.x (upgrade notes). Under Strict Mode, a variable that is in none of env, globalEnv, passThroughEnv or an inferred framework prefix is filtered out of the process environment turbo hands to the task, before the command even starts.

That filtering has one large exception, and it applies locally: Turborepo's own documentation says plainly that it "does not load .env files into your task's runtime, leaving them to be handled by your framework, or tools like dotenv." A variable that only ever lives in a .env.local file in the Next.js app's own folder reaches Next.js through Next.js's own loader, on disk, regardless of Strict Mode — Turborepo never put it in the process environment to filter out in the first place. The question's project keeps its five variables in a .env.local file for local development, though the thread does not show whether that file sits in the app or at the monorepo root, which is the one placement Turborepo's own guidance advises against. What Strict Mode can genuinely strip before a task starts is a variable that only exists in the invoking shell, in CI, or injected by a hosting platform — and a platform's dashboard-set variables are exactly that kind, process environment, not a .env file, so they are the ones Strict Mode can filter. On Vercel specifically, Turborepo already cross-checks the project's dashboard-configured variables against turbo.json and warns about anything missing, which is the same declaration this error asks for, from the other direction.

The rule's allowList option changes neither path. It silences the ESLint warning for a variable the rule's own documentation says belongs there only when it has no effect on the build output, and it never writes the variable into globalEnv or a task's env. Adding a shell- or CI-sourced secret to allowList purely to clear the red underline leaves Strict Mode free to still filter it out of that task's process at run time.

If the lint passes but the app still does not see the value#

Three unrelated problems share the same symptom once turbo.json is correct, and each has a different fix:

  • The variable is declared in turbo.json but was never set in the environment that actually built the app. On Vercel that means the dashboard's Environment Variables screen, and a variable added there does not apply to a build that already ran — see Next.js environment variables undefined on Vercel for the five checks, in order, including the redeploy a newly added variable needs.
  • A Prisma schema calling env("DATABASE_URL") has its own, entirely separate loading step that has nothing to do with turbo.json or ESLint — see Prisma: Environment variable not found: DATABASE_URL if that is the exact message instead.
  • A client-side variable without a recognised prefix was never inlined into the JavaScript bundle in the first place; that is a build-time substitution problem independent of turbo.json. Vite's equivalent mechanism uses its own prefix instead of NEXT_PUBLIC_, and fails in a similarly shaped but unrelated way when it is missing — see import.meta.env undefined on production build (Vite).

The current Next.js CLI reference lists dev, build, start, info, telemetry, typegen, upgrade and analyze as its commands — no lint — and Next.js's own version 16 upgrade guide says directly: "The next lint command has been removed. Use Biome or ESLint directly. next build no longer runs linting." That is why next build might not surface this error on its own any more; eslint-plugin-turbo still plugs into a standalone flat eslint.config.mjs the same way it does above; see the ESLint 9 flat-config migration for the rest of what moved around it.

This error reads exactly as literally as it sounds: a process.env access with no entry covering it in turbo.json, fixed by adding one string to one array. What is worth double-checking before moving on is whether that string matches the variable name character for character, and whether the fix was written where turbo run can still see it at build time, not only where ESLint can.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote