Fix: Cloudflare Pages Not Working — Build Output, Functions Routing, _redirects, and Bindings
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 FoundOr 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 301GET /old-url → 404Why 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.jsserves/api/hello. Misplacing them (e.g. under/src/functions/) does nothing. _redirectsand_headersmust 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 supportwrangler.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. Useoutput: "export"innext.config.js. - Next.js (full): use the
@cloudflare/next-on-pagesadapter — output dir.vercel/output/static. - Vite: output dir
dist. - SvelteKit: output dir varies by adapter —
.svelte-kit/cloudflarewith@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 200The 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.comFor 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 typesThis 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-cacheOr 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"; // ✅ worksFor framework adapters like @cloudflare/next-on-pages, set the right flags:
nodejs_compatThe 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-appThis 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-branchCloudflare 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=webPages 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, setbasein your build tool (Vite, Astro, etc.). wrangler pages devworks but production fails. Local dev uses Node-compat by default; production may not. Addnodejs_compatto compatibility flags.- Function 504 timeout. Pages Functions have a 30-second wall-clock limit. For long-running work, use Workers (with
wrangler.tomlWorkers 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), setCache-Control: no-cachein_headers. _worker.jsoverrides 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
_redirectsto 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Cloudflare Durable Objects Not Working — ID Strategy, Storage API, WebSocket Hibernation, Alarms
How to fix Cloudflare Durable Objects errors — idFromName vs newUniqueId, Storage transactions, blockConcurrencyWhile, WebSocket Hibernation API, alarms, migrations, and class binding setup.
Fix: Cloudflare Queues Not Working — Producer Binding, Consumer Worker, Batching, and Dead Letter
How to fix Cloudflare Queues errors — producer queue.send not delivering, consumer not invoking, ack/retry/DLQ patterns, batch size limits, max_retries, content type pitfalls, and local dev with wrangler.
Fix: Cloudflare Workers AI Not Working — AI Binding, Model IDs, Streaming, and Vectorize Integration
How to fix Cloudflare Workers AI errors — env.AI binding setup, model ID format, text-generation streaming with ReadableStream, AI Gateway, Vectorize embeddings, region availability, and Neuron-based pricing.
Fix: Fly.io Deploy Not Working — fly.toml, Machines, Volumes, Secrets, and Internal DNS
How to fix Fly.io errors — fly.toml app vs name confusion, machines API vs legacy apps, Dockerfile build failures, volume per-region, secrets staging, fly proxy for local access, and internal IPv6 routing.