This note explains how to wire Cloudflare Pages to a Worker with service bindings, how the relevant files fit together, how to handle multiple environments, and what workers_dev actually does.

The short version

If your stack looks like this:

  • Pages serves the frontend

  • Workers serves the API and /auth/*

  • Pages calls the Worker through a service binding

then these three conditions must hold:

  1. The Pages project must declare [[services]]

  2. If you define [env.production], you must also declare [[env.production.services]]

  3. The Worker name must match the service value used by Pages

If any of those is missing, the Pages Function can end up with an empty context.env, which means your binding is absent at runtime.


What each file is responsible for

apps/web/wrangler.toml

This is the source of truth for the Pages project. It defines:

  • the Pages project name

  • the static output directory

  • service bindings

  • environment variables

apps/web/functions/[[path]].ts

This is the Pages Functions entrypoint. It:

  • intercepts /api/*

  • intercepts /auth/*

  • forwards the request to the Worker via a service binding

apps/api/wrangler.toml

This is the Worker project configuration. It defines:

  • the Worker name

  • development and production environment variables

  • D1 / KV / R2 bindings

  • whether the Worker keeps a workers.dev default domain


Suggested project layout

A clean split usually looks like this:

apps/
  web/
    wrangler.toml
    functions/[[path]].ts
    src/
  api/
    wrangler.toml
    src/

In this setup:

  • web owns Pages and request forwarding

  • api owns business logic and authentication


Pages-side configuration

1. Top-level config

apps/web/wrangler.toml

name = "react-hono-starter"
pages_build_output_dir = "dist"
compatibility_date = "2024-09-25"

# Bind to the API worker - proxies /api/* and /auth/* without a network hop
[[services]]
binding = "WORKER"
service = "react-hono-starter-api"

[vars]
# Public vars accessible in the browser

What this means:

  • name is the Pages project name

  • pages_build_output_dir is the build output directory

  • [[services]] creates the service binding available to Pages Functions

  • binding = "WORKER" means context.env.WORKER

  • service = "react-hono-starter-api" must match the actual Worker script name

2. Production config

If you define [env.production], you must treat it as its own environment block.

Some keys are inheritable, some are not.
services belongs to the set that should be declared explicitly in the target environment.

So production should also include:

[env.production]
vars = {}

[[env.production.services]]
binding = "WORKER"
service = "react-hono-starter-api"

This is the piece that matters most when a project works locally but the production Pages deployment has no binding at runtime.


Pages Functions entrypoint

apps/web/functions/[[path]].ts

interface Env {
  WORKER: Fetcher;
}

export const onRequest: PagesFunction<Env> = (ctx) => {
  const { pathname } = new URL(ctx.request.url);

  if (pathname.startsWith('/api/') || pathname.startsWith('/auth/')) {
    if (!ctx.env.WORKER) {
      return new Response('Missing worker service binding', { status: 500 });
    }
    return ctx.env.WORKER.fetch(ctx.request);
  }

  return ctx.next();
};

Two details matter here:

  • ctx.env.WORKER does not exist automatically; it comes from wrangler.toml

  • /api/* and /auth/* are just forwarded to the Worker

So the Pages Function is a transport layer, not the business layer.


Worker-side configuration

apps/api/wrangler.toml

name = "react-hono-starter-api"
main = "src/worker.ts"
compatibility_date = "2024-09-25"
compatibility_flags = ["nodejs_compat"]
workers_dev = false

[dev]
port = 8013

The important part is:

  • name = "react-hono-starter-api"

  • Pages service must match this name

  • workers_dev = false only affects the default *.workers.dev domain

The rest of the Worker config covers its own bindings:

  • D1

  • KV

  • R2

  • secrets and environment variables

Those bindings do not directly affect the Pages service binding, but they do affect whether the Worker can process requests successfully once the binding is in place.


Multi-environment setup

One Worker, multiple environments

This fits the current project well:

[env.production]
vars = { ENVIRONMENT = "production", IS_DEBUG = "false" }

[[env.production.services]]
binding = "WORKER"
service = "react-hono-starter-api"

You can also define a development environment:

[env.dev]
vars = { ENVIRONMENT = "development", IS_DEBUG = "true" }

[[env.dev.services]]
binding = "WORKER"
service = "react-hono-starter-api"

Separate production and dev Workers

If you want a layout closer to sso-serverless, you can split the bindings:

[[services]]
binding = "BACKEND_PROD"
service = "react-hono-starter-api"

[[services]]
binding = "BACKEND_DEV"
service = "react-hono-starter-api-dev"

Then choose the binding at runtime:

const backend = hostname.includes('localhost') ? env.BACKEND_DEV : env.BACKEND_PROD;
return backend.fetch(request);

This is useful when:

  • production and preview environments are separate

  • you have multiple domains

  • you want explicit environment selection


What workers_dev does

workers_dev is a Worker-level switch.

When enabled

The Worker gets a default *.workers.dev public URL.

When disabled

The Worker no longer exposes that default public URL.

What it does not do

It does not:

  • create Pages service bindings

  • make context.env.WORKER appear

  • fix a missing binding in Pages

When to disable it

If the Worker is only meant to be called internally by Pages, and you do not want an extra public endpoint, then disabling it is a good idea:

workers_dev = false

This reduces exposure and keeps the deployment model explicit.


Why the issue happened

The failure was configuration-related, not code-related.

The live Pages project initially did not carry the service binding into the production environment, so:

  • the Pages Function still executed

  • context.env had no WORKER

  • requests to /auth/me and /auth/login failed immediately with a missing binding

The fix is:

  1. Declare top-level [[services]]

  2. Declare [[env.production.services]]

  3. Keep the Worker name and Pages service aligned

  4. Redeploy Pages


Minimal working template

apps/web/wrangler.toml

name = "react-hono-starter"
pages_build_output_dir = "dist"
compatibility_date = "2024-09-25"

[[services]]
binding = "WORKER"
service = "react-hono-starter-api"

[env.production]
vars = {}

[[env.production.services]]
binding = "WORKER"
service = "react-hono-starter-api"

apps/web/functions/[[path]].ts

interface Env {
  WORKER: Fetcher;
}

export const onRequest: PagesFunction<Env> = (ctx) => {
  const { pathname } = new URL(ctx.request.url);

  if (pathname.startsWith('/api/') || pathname.startsWith('/auth/')) {
    return ctx.env.WORKER.fetch(ctx.request);
  }

  return ctx.next();
};

apps/api/wrangler.toml

name = "react-hono-starter-api"
main = "src/worker.ts"
compatibility_date = "2024-09-25"
compatibility_flags = ["nodejs_compat"]
workers_dev = false

Summary

In a Cloudflare Pages + Workers architecture, the most common failure mode is not code, but configuration inheritance.

Keep these three terms in mind:

  • service binding

  • env.production

  • workers_dev

The first two determine whether Pages can forward requests to the Worker. The last one only controls whether the Worker has a default workers.dev public endpoint.


Download this blog in MD format: https://blog-media.ropean.org/2026/06/efd544d8-ff24-4a6e-8ab4-1b22dec2df3d.md