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.
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.
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-varsThis 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-levelglobalEnv; - or in the
envarray of a task defined at the root with no workspace prefix (a plainbuildorlintentry) — 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
envof that workspace's own task — either a"workspaceName#task"entry in the rootturbo.json, or a task defined directly in that workspace's ownturbo.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 legacydependsOn/globalDependenciesarrays, the conventionturbo.jsonused before it had dedicatedenvandglobalEnvkeys; - or it is a key inside a file listed under a task's
dotEnv, or the root'sglobalDotEnv— older keys read bydotenv-processing.ts, which parses each listed file and collects its variable names. The officialmigrate-dot-envcodemod moves a task'sdotEnvintoinputsfor Turborepo 2.0, and the current schema no longer documents either key, but the rule's ownProjectclass 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:
/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:
/work/next-var.js
2:15 error TOTALLY_UNDECLARED_VAR is not listed as a dependency in turbo.json turbo/no-undeclared-env-varsRemoving 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:
/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-varsWhat 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#
-
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 thepipelinekeyturbo.jsonused before Turborepo 2.0: reproduced directly, eslint-plugin-turbo 2.11.10 still reads apipeline-keyed file correctly for this one rule, but that is this plugin's own backward compatibility, not a claim about whatturbo runitself does with one — forturboitself, migrate with the official@turbo/codemod rename-pipeline, which turns everypipelinekey intotasks: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/**"] } } } -
Prefer a task's own
envarray when only that task reads the variable. For a value that actually reachesturboas a process environment variable — the shell, CI, a platform — aglobalEnventry changes the cache hash of every task the moment that value changes; a task-scopedenventry only changes that task's hash, so an unrelatedlintortesttask keeps its cache:turbo.json json{ "tasks": { "build": { "env": ["NEXT_STRIPE_SK", "NEXT_PUBLIC_STRIPE_PK"], "outputs": [".next/**"] }, "lint": { "outputs": [] } } }Neither
envnorglobalEnvdoes that for a value that only ever lives in a.envfile —turbonever 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 underinputsinstead, 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/**"] } } }inputsonly fixes that caching gap; it does nothing for this lint rule.calculate-inputs.tsbuilds its declared-variable list fromglobalEnv/env,globalPassThroughEnv/passThroughEnv, the legacy$-prefixeddependsOn/globalDependencies, and thedotEnvkeys above — never frominputs— so the variable itself still needs one of those, or a framework prefix, for the error to go away. -
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.jsonof the package containing the file being linted — not the monorepo root — fornext(forNEXT_PUBLIC_*) in itsdependencies,devDependenciesorpeerDependencies; the rule reads all three. Other frameworks key on their own package name the same way —viteforVITE_*,react-scriptsforREACT_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:
npx eslint --no-color index.js; echo "EXIT:$?"EXIT:0The 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:
{
"$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 —
/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.jsonbut 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 withturbo.jsonor 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 ofNEXT_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
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix: Do Not Use Array Index in Keys (React)
Why key={index} breaks on reorder and removal, how to prove it with an uncontrolled input, and the three ways to get a real key when your data has no id.
Fix "only exports components": Fast Refresh ESLint error
The exact ESLint rule that throws this, the six code paths that produce six different wordings, and the one move that actually silences it.
Fix Cannot resolve symbol 'Routes': stale JetBrains typings
Why a JetBrains IDE flags Routes from react-router-dom 6 as unknown while the app builds, how to prove which typings file it reads, and the order of steps that stops the old copy coming back.


