Skip to content

Fix: Cloudflare Pages Not Working — Build Output, Functions Routing, _redirects, and Bindings

FixDevs ·

Quick Answer

How to fix Cloudflare Pages errors — build output directory mismatch, Functions in /functions/, _redirects vs _headers, compatibility flags, env per branch, D1/R2/KV bindings, and Direct Upload alternatives.

The Error

You connect a Git repo to Cloudflare Pages and the build succeeds but serves a 404:

[Build] Success
[Deploy] Live at https://my-app.pages.dev
# Visit the URL → 404 Not Found

Or your Functions route doesn’t trigger:

// functions/api/hello.js
export function onRequest() {
  return new Response("hello");
}
GET /api/hello → 404 (served from static site instead of function)

Or env vars work in production but not in preview:

[preview] DATABASE_URL: undefined
[production] DATABASE_URL: postgres://...

Or _redirects is ignored:

# public/_redirects
/old-url  /new-url  301
GET /old-url → 404

Why This Happens

Cloudflare Pages runs in two modes:

  • Static site — pre-built files served from edge. Like GitHub Pages or Netlify static.
  • Static + Functions — static files plus serverless functions in /functions/. The functions run on Workers infrastructure.

Most issues map to one of:

  • Wrong output directory. Pages serves the contents of a single output directory. Misnaming or wrong subdirectory means an empty site.
  • Functions placement and routing. Files in /functions/ map to URL paths. functions/api/hello.js serves /api/hello. Misplacing them (e.g. under /src/functions/) does nothing.
  • _redirects and _headers must be in the output dir. Putting them in source but not bundling them to the output skips them.
  • Bindings (D1, R2, KV) live in the dashboard, not wrangler.toml. Pages historically used dashboard-only bindings; recent versions support wrangler.toml, but the migration is incomplete.

Fix 1: Configure Build Output Directory

In the Cloudflare Pages dashboard → Settings → Builds & deployments:

Production branch:      main
Build command:          npm run build
Build output directory: dist   (or "build" / "out" / ".vercel/output/static" / "public")
Root directory:         /      (or "/apps/web" for monorepos)

For common frameworks:

  • Astro: output dir dist.
  • Next.js (static export): output dir out. Use output: "export" in next.config.js.
  • Next.js (full): use the @cloudflare/next-on-pages adapter — output dir .vercel/output/static.
  • Vite: output dir dist.
  • SvelteKit: output dir varies by adapter — .svelte-kit/cloudflare with @sveltejs/adapter-cloudflare.
  • Remix (with @remix-run/cloudflare-pages): output dir public + Functions auto-generated.
  • Hugo: output dir public.

After the first deploy, check the Files tab — Pages shows what got uploaded. An empty Files list means the output directory is wrong.

Pro Tip: For SPAs (React Router, Vue Router with history mode), add a catch-all to _redirects so deep links work:

/*  /index.html  200

The 200 (not 301) makes it a rewrite, not a redirect — the URL bar stays the same but index.html is served.

Fix 2: Place Functions Correctly

Functions live in /functions/ at the root of your output directory:

project/
├── public/             # static files (Vite copies these)
├── functions/          # Pages Functions — SAME LEVEL as public/
│   ├── api/
│   │   ├── hello.js    # → /api/hello
│   │   └── users/
│   │       └── [id].js # → /api/users/:id
│   └── _middleware.js  # runs on every Function call
└── src/

Your build process must not put functions in the output directory — Cloudflare picks them up from the repo root or your configured “Root directory.”

Function file:

// functions/api/hello.js
export async function onRequest(context) {
  return new Response("hello");
}

// Method-specific:
export async function onRequestGet(context) {
  return new Response("GET hello");
}

export async function onRequestPost({ request }) {
  const body = await request.json();
  return Response.json({ received: body });
}

context includes request, env (bindings), params (path params), and helpers (next, data).

For dynamic paths:

functions/api/users/[id].js   → /api/users/123  (params.id = "123")
functions/api/[[catchall]].js → /api/anything   (params.catchall = ["anything"])

Common Mistake: Putting functions under src/functions/ or app/api/. Pages Functions only sees the top-level functions/ directory. For Next.js App Router with API routes, use @cloudflare/next-on-pages instead of /functions/.

Fix 3: _redirects and _headers

These files must end up in the output directory at deploy time. If they’re in public/ and your build copies them — good. If they’re at project root but the build doesn’t include them — they’re missing.

_redirects syntax:

# Format: <source>  <destination>  [<status>]
/old-blog/*       /blog/:splat              301
/api/legacy/*     /api/v2/:splat            301
/*                /index.html               200    # SPA fallback
/login            /auth/login               302
/region/japan/*   /jp/:splat                301!   # Force (override even if file exists)

Status codes:

  • 200 — rewrite (URL stays same; serves the destination file).
  • 301 — permanent redirect.
  • 302 — temporary redirect.
  • 404 — explicitly return 404.

For path parameters: * matches one segment; :name captures a named param.

_headers syntax:

# Apply headers to URL patterns
/*
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin

/assets/*
  Cache-Control: public, max-age=31536000, immutable

/api/*
  Access-Control-Allow-Origin: https://example.com

For pre-built static assets with hashed filenames, the immutable Cache-Control is gold — CDN caches them indefinitely and revalidates only on cache miss.

Common Mistake: Putting _redirects outside the output directory and assuming Cloudflare scans the repo. It only reads files in the deployed output.

Fix 4: Environment Variables Per Branch

Production env vars apply to the production branch (main by default). Preview env vars apply to all other branches.

In the dashboard → Settings → Environment variables:

Production:
  DATABASE_URL=postgres://prod-db...
  STRIPE_KEY=sk_live_...

Preview:
  DATABASE_URL=postgres://staging-db...
  STRIPE_KEY=sk_test_...

In Functions:

export async function onRequest({ env }) {
  return Response.json({ db: env.DATABASE_URL });
}

env includes both env vars and bindings (D1, R2, KV).

For local dev with wrangler pages dev:

# Reads .dev.vars (gitignored) for local env:
echo "DATABASE_URL=postgres://localhost" > .dev.vars

wrangler pages dev dist

.dev.vars is the Pages equivalent of .env.local — same KEY=VALUE format.

Pro Tip: Use separate Cloudflare projects for staging and production. Then “preview” and “production” environments each have clear scopes — no risk of preview env leaking into production by branch promotion.

Fix 5: Bindings (D1, R2, KV)

In the dashboard → Settings → Functions → Bindings:

  • D1 databases: bind a name to a D1 database.
  • R2 buckets: bind a name to an R2 bucket.
  • KV namespaces: bind a name to a KV namespace.
  • Durable Object namespaces: bind a name to a DO class.
  • Queues: bind a name to a Queue.

Each binding has a Production and Preview slot — same DB name, but separate databases.

In Functions:

export async function onRequest({ env }) {
  // D1 binding named "DB" in the dashboard:
  const { results } = await env.DB.prepare("SELECT * FROM users").all();
  return Response.json(results);
}

Generate types:

wrangler types

This creates worker-configuration.d.ts with bindings typed.

For local development with bindings, use wrangler pages dev:

wrangler pages dev dist \
  --d1 DB=my-app-prod \
  --r2 BUCKET=my-app-bucket \
  --kv CACHE=my-app-cache

Or via wrangler.toml (recent Wrangler):

name = "my-pages-app"
compatibility_date = "2026-05-01"
pages_build_output_dir = "dist"

[[d1_databases]]
binding = "DB"
database_name = "my-app-prod"
database_id = "..."

With wrangler.toml, you can deploy via wrangler pages deploy instead of the dashboard’s Git integration.

Fix 6: Compatibility Flags

Some Workers features are behind compatibility flags. Set them in dashboard → Settings → Functions → Compatibility flags:

  • nodejs_compat — enables a Node.js compatibility layer (Node Buffer, async_hooks, some others).
  • streams_enable_constructors — older streams API.

For Node-API-using packages (lots of npm libs), nodejs_compat is essential:

// Without nodejs_compat:
const { Buffer } = require("node:buffer");  // ❌ fails

// With nodejs_compat:
import { Buffer } from "node:buffer";       // ✅ works

For framework adapters like @cloudflare/next-on-pages, set the right flags:

nodejs_compat

The adapter’s docs list required flags.

Common Mistake: Setting flags only in Production. Preview deploys may fail with the same code because the flags weren’t copied. Set both Production and Preview env compatibility flags.

Fix 7: Direct Upload Without Git

For deploy pipelines that don’t use Cloudflare’s Git integration:

npm run build
wrangler pages deploy dist --project-name=my-app

This uploads the dist/ directory directly. Useful for:

  • Monorepos with custom CI logic.
  • Deploys from a non-Git source (local, build artifact).
  • Per-PR deployments coordinated by your CI rather than Cloudflare.

For preview deploys via Direct Upload, pass --branch:

wrangler pages deploy dist --project-name=my-app --branch=feature-branch

Cloudflare creates a preview URL keyed to that branch name.

Pro Tip: For CI Direct Upload, use a scoped API token with Cloudflare Pages: Edit permission. Don’t use your account-level token — Direct Upload doesn’t need account-wide access.

Fix 8: Monorepo Setup

For monorepos where Pages should build a sub-app:

In the dashboard → Settings → Builds & deployments:

Root directory: apps/web        # Pages cd's here before building
Build command: pnpm build       # Run from apps/web
Build output directory: dist    # Relative to root directory (so apps/web/dist)

For Turborepo / pnpm workspaces, ensure your CI installs the right deps:

# Build command (in Pages dashboard):
pnpm --filter web build

# Or with Turborepo:
pnpm turbo run build --filter=web

Pages installs pnpm (or npm, yarn, bun) based on the lockfile present at the repo root.

Common Mistake: Mistaking “build output directory” as relative to repo root. It’s relative to Root directory. If your Root directory is apps/web, your output is apps/web/dist from the repo root but you set Build output directory: dist.

Still Not Working?

A few less-obvious failures:

  • Site loads but assets 404. Path mismatch. Check that your build’s asset URLs (/assets/index-abc.js) point to where Pages serves them. If you’re behind a CDN or sub-path, set base in your build tool (Vite, Astro, etc.).
  • wrangler pages dev works but production fails. Local dev uses Node-compat by default; production may not. Add nodejs_compat to compatibility flags.
  • Function 504 timeout. Pages Functions have a 30-second wall-clock limit. For long-running work, use Workers (with wrangler.toml Workers project) which has higher limits on paid plans.
  • Bindings populated in production, undefined in preview. Bindings are set per environment. Configure both Production and Preview separately in the dashboard.
  • Old static files cached after deploy. Cloudflare’s CDN caches aggressively. New deploys get fresh URLs (hashed asset filenames). For unhashed files (index.html), set Cache-Control: no-cache in _headers.
  • _worker.js overrides all Functions. If your output contains a _worker.js (e.g. some frameworks emit it), it captures all routing — Functions in /functions/ won’t run. Pick one mode.
  • HTTPS-only domain redirects break OAuth flow. Some OAuth callbacks use http. Add the http URL to your OAuth provider’s allowed URLs and set _redirects to 308 (preserves POST method) instead of 301.
  • CI deploys faster than DNS propagates. New deploys are live in seconds, but DNS changes for custom domains take longer. Always include the deploy URL (my-app.pages.dev) for verification.

For related Cloudflare and deployment issues, see Cloudflare D1 not working, Cloudflare R2 not working, Wrangler not working, and Vercel deployment failed.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles