Cloudflare Pages
Deploy frontend projects with preview builds, edge functions, and global CDN delivery on Cloudflare's network.
When to Use
- Deploying static sites (React, Vue, Astro, Hugo, Next.js static export).
- Full-stack applications using Pages Functions for server-side logic.
- Projects that need automatic preview deployments per pull request.
- Teams that want zero-config CDN with custom domain and TLS.
- Migrating from Vercel, Netlify, or GitHub Pages to Cloudflare's ecosystem.
Prerequisites
- Node.js 18+ and npm installed locally.
- A Cloudflare account (free tier works for most projects).
- Wrangler CLI installed:
npm install -g wrangler. - Authenticated via
wrangler loginorCLOUDFLARE_API_TOKENenvironment variable. - Source code in a Git repository (GitHub or GitLab for dashboard integration).
Project Setup via Wrangler
Create a New Project
bash# Create a new Pages project npx wrangler pages project create my-site # List existing projects npx wrangler pages project list # Delete a project (removes all deployments) npx wrangler pages project delete my-site
Deploy from Local Build Output
bash# Build your framework first npm run build # Deploy the output directory npx wrangler pages deploy dist --project-name=my-site # Deploy with a custom branch name (triggers preview URL) npx wrangler pages deploy dist --project-name=my-site --branch=feature-auth # Deploy and get the deployment URL in JSON npx wrangler pages deploy dist --project-name=my-site --branch=main 2>&1 | tail -1
List and Manage Deployments
bash# List recent deployments npx wrangler pages deployment list --project-name=my-site # Tail live logs from a deployment npx wrangler pages deployment tail --project-name=my-site --environment=production
Dashboard Git Integration
- Navigate to Workers & Pages > Create application > Pages.
- Connect your GitHub or GitLab account.
- Select the repository and configure:
- Production branch:
main - Build command:
npm run build - Build output directory:
dist(orbuild,.next,publicdepending on framework)
- Production branch:
- Set environment variables per environment (Production vs Preview).
Framework Presets
Cloudflare auto-detects frameworks. Override if needed:
| Framework | Build Command | Output Directory |
|---|---|---|
| React CRA | npm run build | build |
| Vite | npm run build | dist |
| Next.js | npx @cloudflare/next-on-pages | .vercel/output/static |
| Astro | npm run build | dist |
| Hugo | hugo | public |
| SvelteKit | npm run build | .svelte-kit/cloudflare |
Preview Deployments
Every non-production branch gets a unique preview URL automatically.
# URL format for preview deployments https://<commit-hash>.<project-name>.pages.dev https://<branch-name>.<project-name>.pages.dev
Branch-Based Access Control
bash# Set preview branch patterns in wrangler.toml (Pages-specific) # Or configure via dashboard: Settings > Builds & deployments # Include branches: feature/*, staging # Exclude branches: dependabot/*
Preview Comment on Pull Requests
Enable the Cloudflare Pages GitHub App to post deployment URLs as PR comments. Configure under Settings > Builds & deployments > Preview comment.
Pages Functions
Pages Functions provide server-side logic deployed alongside your static site. Place files in a functions/ directory at the project root.
Basic API Route
typescript// functions/api/hello.ts export const onRequestGet: PagesFunction = async (context) => { return new Response(JSON.stringify({ message: "Hello from the edge" }), { headers: { "Content-Type": "application/json" }, }); }; // functions/api/users/[id].ts — dynamic route parameter export const onRequestGet: PagesFunction = async (context) => { const userId = context.params.id; return new Response(JSON.stringify({ userId }), { headers: { "Content-Type": "application/json" }, }); };
Middleware
typescript// functions/_middleware.ts — runs before all routes export const onRequest: PagesFunction = async (context) => { const authHeader = context.request.headers.get("Authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) { return new Response("Unauthorized", { status: 401 }); } return context.next(); };
Functions with Bindings
typescript// functions/api/data.ts — using KV and D1 bindings interface Env { MY_KV: KVNamespace; MY_DB: D1Database; MY_BUCKET: R2Bucket; } export const onRequestGet: PagesFunction<Env> = async (context) => { // Read from KV const cached = await context.env.MY_KV.get("key"); if (cached) return new Response(cached); // Query D1 const result = await context.env.MY_DB.prepare( "SELECT * FROM items LIMIT 10" ).all(); // Cache in KV await context.env.MY_KV.put("key", JSON.stringify(result.results), { expirationTtl: 300, }); return Response.json(result.results); };
Wrangler Configuration
toml# wrangler.toml — Pages project configuration name = "my-site" compatibility_date = "2024-09-01" pages_build_output_dir = "dist" # KV namespace binding [[kv_namespaces]] binding = "MY_KV" id = "abc123def456" # D1 database binding [[d1_databases]] binding = "MY_DB" database_name = "my-app-db" database_id = "xxxx-yyyy-zzzz" # R2 bucket binding [[r2_buckets]] binding = "MY_BUCKET" bucket_name = "app-assets" # Environment variables [vars] API_BASE_URL = "https://api.example.com"
Headers and Redirects
Custom Headers
# public/_headers /assets/* Cache-Control: public, max-age=31536000, immutable /* X-Frame-Options: DENY X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' /api/* Access-Control-Allow-Origin: https://example.com Access-Control-Allow-Methods: GET, POST, OPTIONS
Redirects
# public/_redirects /old-page /new-page 301 /blog/:slug /posts/:slug 301 /docs/* https://docs.example.com/:splat 302 /home / 302
Custom Domains
bash# Add a custom domain via Cloudflare dashboard: # Pages project > Custom domains > Set up a custom domain # Or via API curl -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/pages/projects/my-site/domains" \ -H "Authorization: Bearer $CF_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"www.example.com"}'
CI/CD Integration
GitHub Actions
yaml# .github/workflows/deploy.yml name: Deploy to Cloudflare Pages on: push: branches: [main] pull_request: jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - run: npm run build - uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: pages deploy dist --project-name=my-site
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Build fails with out-of-memory | Build exceeds 1 GB RAM limit | Reduce dependencies; use NODE_OPTIONS=--max_old_space_size=768 |
| Functions return 404 | functions/ directory not at project root | Move functions/ to repo root, not inside src/ |
| Preview URL shows old content | Browser cache or stale deployment | Hard refresh; check deployment list for latest commit hash |
| Custom domain shows SSL error | DNS not proxied through Cloudflare | Enable orange cloud (proxy) on the CNAME record |
_headers file ignored | File not in build output directory | Place in public/ so it copies to dist/ during build |
| Bindings undefined in Functions | Missing wrangler.toml or dashboard config | Add bindings in wrangler.toml and redeploy |
| 1 MB function size limit exceeded | Too many dependencies bundled | Tree-shake; move large deps to KV or R2 |
Related Skills
- cloudflare-workers - Edge backend logic and API routes
- cloudflare-r2 - Object storage for assets and uploads
- cloudflare-zero-trust - Protect preview deployments with Access policies
- cdn-setup - General CDN configuration patterns


