Cloudflare Pages Functions Read undefined Environment Variables at Runtime
Pages Functions return undefined for environment bindings at runtime, and code such as console.log(process.env.MY_SECRET) or process.env.API_URL does not produce the bound value.
Why this happens
Cloudflare Pages Functions do not use Node.js process.env as the source of runtime configuration. The runtime injects bindings into the request context, and those bindings are read from the env object that Cloudflare passes to the handler.
That means a Pages Function can see secrets and variables only if:
- the binding exists in the Pages project configuration
- the binding name matches exactly
- the code reads from the runtime
envobject, not fromprocess.env
When process.env.MY_SECRET is undefined, the cause is usually not that the value is missing from the deployment. It is that the value was never exposed through the Node-style environment interface in the first place.
Cloudflare’s Pages deployment model is different from a traditional Node process. Pages builds your site, then serves Functions in the Cloudflare runtime. During request handling, the platform provides the function with a per-request environment object that contains:
- variables declared in the Pages project
- secrets added in the dashboard or with the CLI
- service bindings and other runtime bindings
That runtime model is why code that works in a local Node script can fail in a Pages Function.
The runtime binding model
Pages Functions receive a handler env object through the function signature or through context.env. The value is not read from a global process environment.
A typical Function looks like this:
tsexport const onRequestGet: PagesFunction = async (context) => { const apiUrl = context.env.API_URL; const secret = context.env.MY_SECRET; return new Response(JSON.stringify({ apiUrl, secret }), { headers: { "content-type": "application/json" }, }); };
In this model, API_URL and MY_SECRET are binding names. They must match the names configured in the Pages project.
If you write this instead:
tsexport const onRequestGet: PagesFunction = async () => { const apiUrl = process.env.API_URL; const secret = process.env.MY_SECRET; return new Response(JSON.stringify({ apiUrl, secret }), { headers: { "content-type": "application/json" }, }); };
the values are usually undefined in Pages Functions, even when the same variables are configured in the project.
The reason is simple: the Pages Function runtime is not a normal Node server process with a populated process.env. The values are injected into the function context for each request.
What to configure in the Pages project
Pages variables and secrets must be declared in the Pages project, not only in your shell and not only in a local .env file.
You can set them in the Cloudflare dashboard under the Pages project settings, or with the Wrangler CLI.
For example, to add a variable with Wrangler:
bashwrangler pages deployment secret put MY_SECRET --project-name my-pages-app
For non-secret variables, use the Pages variables UI in the dashboard or the equivalent Wrangler Pages command for your setup. The important point is that the binding name becomes part of the runtime contract.
If the project defines API_URL, but the code reads api_url or APIURL, the runtime returns undefined. Binding names are exact and case-sensitive.
This also applies to secrets. A secret named DATABASE_URL is not the same as database_url.
How to read bindings in a Pages Function
Prefer the function env argument or context.env in all Pages Functions.
A standard TypeScript example:
tsexport const onRequestGet: PagesFunction = async (context) => { const { API_URL, MY_SECRET } = context.env; if (!API_URL) { return new Response("Missing API_URL", { status: 500 }); } return new Response(`API_URL=${API_URL}; secret=${MY_SECRET ? "set" : "unset"}`); };
If you want to keep the handler signature explicit, you can destructure env from the context object:
tstype Env = { API_URL: string; MY_SECRET: string; }; export const onRequestGet: PagesFunction<Env> = async ({ env }) => { return new Response(env.API_URL); };
This is the cleanest pattern because it keeps the runtime dependency visible. It also helps TypeScript catch missing or misspelled references if you define the Env type accurately.
Why process.env behaves differently
In Node.js applications, process.env is a process-wide object populated by the operating system and the runtime process launcher. A server started with node server.js can read process.env.API_URL because Node owns that process environment.
Pages Functions run in the Cloudflare edge runtime, not in a long-lived Node process. The platform isolates requests and injects the bindings required for that invocation. The function gets what Cloudflare exposes in the handler context.
That difference matters because process.env is not the canonical binding source in the Pages Functions runtime. Some compatibility layers may expose limited Node-like APIs, but the binding contract for Pages remains the handler env object.
If code depends on process.env, it couples the function to local Node conventions instead of the platform’s runtime contract.
Missing bindings return undefined
If a binding is absent from the Pages project, the handler still runs. The property simply resolves to undefined.
For example:
tsexport const onRequestGet: PagesFunction = async ({ env }) => { const token = env.GITHUB_TOKEN; return new Response(token ?? "undefined"); };
If GITHUB_TOKEN is not configured for that Pages deployment, the response will be undefined.
The same result appears when:
- the binding exists in one environment, such as Preview, but not in Production
- the binding was added under a different name
- the code reads from the wrong namespace or object
- local development uses a different env file than the deployed project
Because missing values usually do not throw immediately, the failure can appear downstream. A fetch to a third-party API may fail with 401 Unauthorized, 403 Forbidden, or TypeError: fetch failed after it constructs a request with an empty token.
That is why validating the binding early is useful.
tsexport const onRequestGet: PagesFunction = async ({ env }) => { if (!env.API_URL) { throw new Error("Missing required binding: API_URL"); } const response = await fetch(env.API_URL); return response; };
Failing fast makes the problem easier to attribute to configuration rather than to the downstream service.
Exact name matching
Binding names are case-sensitive and must match exactly between configuration and code.
These are different bindings:
API_URLApi_Urlapi_url
If the Pages project contains API_URL and the code uses env.Api_Url, the result is undefined.
The same rule applies when you derive values from environment-like names in helper functions. Keep the binding names in one place if possible.
tsconst bindingNames = { apiUrl: "API_URL", secret: "MY_SECRET", } as const; export const onRequestGet: PagesFunction = async ({ env }) => { const apiUrl = env[bindingNames.apiUrl]; const secret = env[bindingNames.secret]; return new Response(JSON.stringify({ apiUrl, hasSecret: Boolean(secret) })); };
Using a shared constant avoids typos and makes renames explicit.
Production versus local development
wrangler pages dev can differ from production in how bindings are supplied.
Local development commonly reads values from local configuration files or from a development environment file, while production reads the bindings configured in the Pages project. If those two sources are not aligned, local output can disagree with deployed output.
For example, you might run:
bashwrangler pages dev ./dist
and the function reads values from local settings, but the production deployment uses a different set of bindings in the Pages project dashboard.
When debugging, verify both environments separately:
- local dev through
wrangler pages dev - deployed production or preview deployment in the Pages dashboard
If local values work and production values are undefined, the issue is usually in Pages project configuration, not in the function code.
If production works and local dev is undefined, the local environment probably is not loading the same bindings that production has.
A common pattern is to keep a local .dev.vars file or equivalent development configuration for Wrangler, but that file does not automatically sync with the Pages project’s deployment bindings. Treat local configuration as a separate input.
Confirm the binding is available at runtime
To confirm that the runtime is receiving the binding, log the relevant keys, not the secret values.
tsexport const onRequestGet: PagesFunction = async ({ env }) => { const keys = Object.keys(env).sort(); return new Response(JSON.stringify(keys, null, 2), { headers: { "content-type": "application/json" }, }); };
This returns the binding names visible to the function. It helps verify whether a variable is present at all.
Do not log secret values in production logs. Checking for presence is enough.
If a required binding is missing from the key list, the Pages project configuration is incomplete or the wrong environment is being checked.
Using TypeScript to catch mistakes
TypeScript can reduce binding-name errors, but only if the Env type is defined correctly.
tsinterface Env { API_URL: string; MY_SECRET: string; } export const onRequestGet: PagesFunction<Env> = async ({ env }) => { const url = new URL(env.API_URL); const auth = env.MY_SECRET; return new Response(JSON.stringify({ host: url.host, hasAuth: Boolean(auth) })); };
This helps in two ways:
env.API_URLandenv.MY_SECRETare available in autocomplete- misspelled property names are compile-time errors
TypeScript cannot prove that the Pages project actually contains the binding, so runtime checks are still necessary. It can only validate the code against the declared type.
For required values, keep both the static type and a runtime guard.
tsfunction requireBinding(value: string | undefined, name: string): string { if (!value) { throw new Error(`Missing required binding: ${name}`); } return value; } export const onRequestGet: PagesFunction = async ({ env }) => { const apiUrl = requireBinding(env.API_URL, "API_URL"); return new Response(apiUrl); };
Common failure modes
The most common reasons for undefined bindings in Pages Functions are:
- reading from
process.envinstead ofcontext.env - configuring the variable in the wrong Pages environment, such as Preview instead of Production
- misspelling the binding name
- expecting a local
.envfile to exist in production - assuming a secret added in one project is available in another project
- using a helper function that reads from the wrong object
Each one produces the same symptom: the value is absent when the handler runs.
To debug systematically, check the binding in the Pages dashboard, confirm the exact casing, and inspect the runtime env object in the function.
Recommended pattern
Use the handler env object for every Pages Function binding, declare bindings in the Pages project, and validate required values on startup or at the top of the handler.
A minimal production-safe version looks like this:
tsinterface Env { API_URL: string; } export const onRequestGet: PagesFunction<Env> = async ({ env }) => { if (!env.API_URL) { return new Response("Missing API_URL", { status: 500 }); } const data = await fetch(env.API_URL); return data; };
For local development, make sure wrangler pages dev is pointed at the same kind of bindings you expect in production. For deployed code, verify the Pages project settings first, because the runtime only exposes what the deployment has been configured to inject.
The practical fix is to read bindings from context.env or the handler env object, not from process.env, and to keep the binding name identical in Pages configuration and code. That pattern matches Cloudflare Pages’ runtime model and prevents undefined values from appearing when the function runs.