Cloudflare Pages — Edge Deployment
Cloudflare Pages deploys static sites and full-stack applications to Cloudflare's global edge network — 300+ data centers worldwide. Requests are served from the nearest edge location, providing sub-50ms latency for most users globally.
The free tier includes unlimited bandwidth, 500 builds per month, and access to Workers, D1, R2, and KV — making it one of the most generous hosting platforms available.
Connect your Git repository to Cloudflare Pages for automatic deployments on every push. Preview deployments are created for every pull request with unique URLs.
| 1 | # Install Wrangler CLI |
| 2 | npm install -g wrangler |
| 3 | |
| 4 | # Login to Cloudflare |
| 5 | wrangler login |
| 6 | |
| 7 | # Create a new Pages project |
| 8 | wrangler pages project create my-app |
| 9 | |
| 10 | # Deploy a static directory |
| 11 | wrangler pages deploy ./dist --project-name my-app |
| 12 | |
| 13 | # Deploy with a framework preset |
| 14 | wrangler pages deploy . --project-name my-app \ |
| 15 | --build-command "npm run build" \ |
| 16 | --build-output-dir "./.next" |
| 1 | name = "my-app" |
| 2 | compatibility_date = "2024-01-15" |
| 3 | |
| 4 | [site] |
| 5 | bucket = "./dist" |
| 6 | |
| 7 | [build] |
| 8 | command = "npm run build" |
| 9 | output = ".next" |
| 10 | |
| 11 | [vars] |
| 12 | NODE_ENV = "production" |
| 13 | |
| 14 | [[env.production.kv_namespaces]] |
| 15 | binding = "CACHE" |
| 16 | id = "abc123..." |
info
Cloudflare Pages has built-in build presets for popular frameworks. The build system auto-detects your framework and configures the build command and output directory.
| Framework | Build Command | Output Dir |
|---|---|---|
| Next.js | npx @cloudflare/next-on-pages | .vercel/output/static |
| Astro | astro build | dist |
| Nuxt | nuxt build | dist |
| Remix | remix vite:build | build/client |
| SvelteKit | npx sv build | .svelte-kit/cloudflare |
| Vite (SPA) | vite build | dist |
| 1 | # Next.js on Cloudflare Pages |
| 2 | name = "my-next-app" |
| 3 | compatibility_date = "2024-01-15" |
| 4 | |
| 5 | [build] |
| 6 | command = "npx @cloudflare/next-on-pages" |
| 7 | output = ".vercel/output/static" |
| 8 | |
| 9 | [vars] |
| 10 | NEXT_PUBLIC_API_URL = "https://api.example.com" |
warning
Workers run JavaScript, TypeScript, Python, or Rust at the edge with zero cold starts. They execute within 5ms of the user and can access Cloudflare's storage primitives directly.
| 1 | // Worker serving API routes at the edge |
| 2 | export default { |
| 3 | async fetch(request: Request, env: Env): Promise<Response> { |
| 4 | const url = new URL(request.url); |
| 5 | |
| 6 | // Route to different handlers |
| 7 | if (url.pathname === "/api/hello") { |
| 8 | return Response.json({ |
| 9 | message: "Hello from the edge!", |
| 10 | region: request.cf?.colo, |
| 11 | timestamp: new Date().toISOString(), |
| 12 | }); |
| 13 | } |
| 14 | |
| 15 | if (url.pathname === "/api/lookup") { |
| 16 | const ip = request.headers.get("cf-connecting-ip"); |
| 17 | const country = request.cf?.country; |
| 18 | |
| 19 | return Response.json({ ip, country }); |
| 20 | } |
| 21 | |
| 22 | if (url.pathname.startsWith("/api/users")) { |
| 23 | // Query D1 database at the edge |
| 24 | const userId = url.pathname.split("/").pop(); |
| 25 | const user = await env.DB.prepare( |
| 26 | "SELECT * FROM users WHERE id = ?" |
| 27 | ).bind(userId).first(); |
| 28 | |
| 29 | if (!user) { |
| 30 | return Response.json({ error: "Not found" }, { status: 404 }); |
| 31 | } |
| 32 | |
| 33 | return Response.json(user); |
| 34 | } |
| 35 | |
| 36 | return Response.json({ error: "Not found" }, { status: 404 }); |
| 37 | }, |
| 38 | }; |
| 39 | |
| 40 | interface Env { |
| 41 | DB: D1Database; |
| 42 | CACHE: KVNamespace; |
| 43 | BUCKET: R2Bucket; |
| 44 | } |
| 1 | # Worker bindings in wrangler.toml |
| 2 | [[kv_namespaces]] |
| 3 | binding = "CACHE" |
| 4 | id = "kv-abc123" |
| 5 | |
| 6 | [[d1_databases]] |
| 7 | binding = "DB" |
| 8 | database_name = "my-database" |
| 9 | database_id = "d1-abc123" |
| 10 | |
| 11 | [[r2_buckets]] |
| 12 | binding = "BUCKET" |
| 13 | bucket_name = "my-bucket" |
best practice
D1 is a serverless SQLite database that runs at the edge. It replicates read copies to every Cloudflare data center for sub-5ms read latency globally, while writes go to the primary in your configured region.
| 1 | # Create a D1 database |
| 2 | wrangler d1 create my-database |
| 3 | |
| 4 | # Execute a migration |
| 5 | wrangler d1 execute my-database --file=./schema.sql |
| 6 | |
| 7 | # Query from the command line |
| 8 | wrangler d1 execute my-database --command "SELECT * FROM users LIMIT 5" |
| 1 | CREATE TABLE users ( |
| 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 3 | email TEXT NOT NULL UNIQUE, |
| 4 | name TEXT NOT NULL, |
| 5 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP |
| 6 | ); |
| 7 | |
| 8 | CREATE INDEX idx_users_email ON users(email); |
| 9 | |
| 10 | CREATE TABLE posts ( |
| 11 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 12 | user_id INTEGER NOT NULL, |
| 13 | title TEXT NOT NULL, |
| 14 | content TEXT, |
| 15 | published BOOLEAN DEFAULT FALSE, |
| 16 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, |
| 17 | FOREIGN KEY (user_id) REFERENCES users(id) |
| 18 | ); |
| 19 | |
| 20 | CREATE INDEX idx_posts_user ON posts(user_id); |
| 21 | CREATE INDEX idx_posts_published ON posts(published); |
| 1 | // D1 queries in a Worker |
| 2 | export default { |
| 3 | async fetch(request: Request, env: Env): Promise<Response> { |
| 4 | const url = new URL(request.url); |
| 5 | |
| 6 | // List users with pagination |
| 7 | if (url.pathname === "/api/users") { |
| 8 | const page = parseInt(url.searchParams.get("page") || "1"); |
| 9 | const limit = parseInt(url.searchParams.get("limit") || "20"); |
| 10 | const offset = (page - 1) * limit; |
| 11 | |
| 12 | const { results, success } = await env.DB.prepare( |
| 13 | "SELECT id, email, name, created_at FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?" |
| 14 | ).bind(limit, offset).all(); |
| 15 | |
| 16 | if (!success) { |
| 17 | return Response.json({ error: "Query failed" }, { status: 500 }); |
| 18 | } |
| 19 | |
| 20 | return Response.json({ users: results, page, limit }); |
| 21 | } |
| 22 | |
| 23 | // Create a user |
| 24 | if (url.pathname === "/api/users" && request.method === "POST") { |
| 25 | const { email, name } = await request.json(); |
| 26 | |
| 27 | try { |
| 28 | await env.DB.prepare( |
| 29 | "INSERT INTO users (email, name) VALUES (?, ?)" |
| 30 | ).bind(email, name).run(); |
| 31 | |
| 32 | return Response.json({ success: true }, { status: 201 }); |
| 33 | } catch (e) { |
| 34 | if (e.message?.includes("UNIQUE")) { |
| 35 | return Response.json({ error: "Email already exists" }, { status: 409 }); |
| 36 | } |
| 37 | throw e; |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | return Response.json({ error: "Not found" }, { status: 404 }); |
| 42 | }, |
| 43 | }; |
info
R2 is S3-compatible object storage with zero egress fees. Store user uploads, static assets, backups, and any large files. Access directly from Workers at the edge.
| 1 | // Upload file to R2 |
| 2 | export default { |
| 3 | async fetch(request: Request, env: Env): Promise<Response> { |
| 4 | const url = new URL(request.url); |
| 5 | |
| 6 | // PUT /api/upload/:key |
| 7 | if (request.method === "PUT" && url.pathname.startsWith("/api/upload/")) { |
| 8 | const key = url.pathname.split("/api/upload/")[1]; |
| 9 | const contentType = request.headers.get("content-type") || "application/octet-stream"; |
| 10 | |
| 11 | await env.BUCKET.put(key, request.body, { |
| 12 | httpMetadata: { contentType }, |
| 13 | customMetadata: { |
| 14 | uploadedAt: new Date().toISOString(), |
| 15 | }, |
| 16 | }); |
| 17 | |
| 18 | return Response.json({ key, contentType }, { status: 201 }); |
| 19 | } |
| 20 | |
| 21 | // GET /api/files/:key |
| 22 | if (url.pathname.startsWith("/api/files/")) { |
| 23 | const key = url.pathname.split("/api/files/")[1]; |
| 24 | const object = await env.BUCKET.get(key); |
| 25 | |
| 26 | if (!object) { |
| 27 | return Response.json({ error: "Not found" }, { status: 404 }); |
| 28 | } |
| 29 | |
| 30 | const headers = new Headers(); |
| 31 | object.writeHttpMetadata(headers); |
| 32 | headers.set("etag", object.httpEtag); |
| 33 | |
| 34 | return new Response(object.body, { headers }); |
| 35 | } |
| 36 | |
| 37 | // List files |
| 38 | if (url.pathname === "/api/files") { |
| 39 | const listed = await env.BUCKET.list({ limit: 100 }); |
| 40 | return Response.json({ |
| 41 | objects: listed.objects.map((o) => ({ |
| 42 | key: o.key, |
| 43 | size: o.size, |
| 44 | uploaded: o.uploaded, |
| 45 | })), |
| 46 | }); |
| 47 | } |
| 48 | |
| 49 | return Response.json({ error: "Not found" }, { status: 404 }); |
| 50 | }, |
| 51 | }; |
best practice
KV is an eventually-consistent, global key-value store optimized for read-heavy workloads. Use it for feature flags, session data, cached API responses, and configuration that changes infrequently.
| 1 | // KV for caching and configuration |
| 2 | export default { |
| 3 | async fetch(request: Request, env: Env): Promise<Response> { |
| 4 | const url = new URL(request.url); |
| 5 | |
| 6 | // GET /api/config/:key |
| 7 | if (url.pathname.startsWith("/api/config/")) { |
| 8 | const key = url.pathname.split("/api/config/")[1]; |
| 9 | const value = await env.CACHE.get(key, { type: "json" }); |
| 10 | |
| 11 | if (!value) { |
| 12 | return Response.json({ error: "Key not found" }, { status: 404 }); |
| 13 | } |
| 14 | |
| 15 | return Response.json({ key, value }); |
| 16 | } |
| 17 | |
| 18 | // PUT /api/config/:key |
| 19 | if (request.method === "PUT" && url.pathname.startsWith("/api/config/")) { |
| 20 | const key = url.pathname.split("/api/config/")[1]; |
| 21 | const value = await request.json(); |
| 22 | |
| 23 | await env.CACHE.put(key, JSON.stringify(value), { |
| 24 | expirationTtl: 3600, // 1 hour |
| 25 | }); |
| 26 | |
| 27 | return Response.json({ success: true }); |
| 28 | } |
| 29 | |
| 30 | // Cached API response pattern |
| 31 | if (url.pathname === "/api/data") { |
| 32 | const cacheKey = "api:data:latest"; |
| 33 | const cached = await env.CACHE.get(cacheKey, { type: "json" }); |
| 34 | |
| 35 | if (cached) { |
| 36 | return Response.json(cached); |
| 37 | } |
| 38 | |
| 39 | // Fetch fresh data |
| 40 | const data = await fetchExternalApi(); |
| 41 | await env.CACHE.put(cacheKey, JSON.stringify(data), { |
| 42 | expirationTtl: 300, |
| 43 | }); |
| 44 | |
| 45 | return Response.json(data); |
| 46 | } |
| 47 | |
| 48 | return Response.json({ error: "Not found" }, { status: 404 }); |
| 49 | }, |
| 50 | }; |
warning
Configure custom domains and routing rules to direct traffic to your Pages project. Cloudflare handles SSL certificates automatically.
| 1 | # wrangler.toml — custom routing |
| 2 | name = "my-app" |
| 3 | routes = [ |
| 4 | { pattern = "example.com", zone_name = "example.com" }, |
| 5 | { pattern = "example.com/api/*", zone_name = "example.com" }, |
| 6 | { pattern = "docs.example.com/*", zone_name = "example.com" }, |
| 7 | ] |
| 1 | // Worker with routing logic |
| 2 | export default { |
| 3 | async fetch(request: Request, env: Env): Promise<Response> { |
| 4 | const url = new URL(request.url); |
| 5 | |
| 6 | // Redirect www to apex |
| 7 | if (url.hostname.startsWith("www.")) { |
| 8 | url.hostname = url.hostname.replace("www.", ""); |
| 9 | return Response.redirect(url.toString(), 301); |
| 10 | } |
| 11 | |
| 12 | // API routes go to Worker |
| 13 | if (url.pathname.startsWith("/api/")) { |
| 14 | return handleApi(request, env); |
| 15 | } |
| 16 | |
| 17 | // Everything else goes to Pages static assets |
| 18 | return env.ASSETS.fetch(request); |
| 19 | }, |
| 20 | }; |
info
Every pull request automatically gets a unique preview URL. This enables reviewing changes in a production-like environment before merging, preventing broken deployments.
| 1 | # GitHub Actions — deploy preview on PR |
| 2 | name: Preview Deploy |
| 3 | on: |
| 4 | pull_request: |
| 5 | branches: [main] |
| 6 | |
| 7 | jobs: |
| 8 | deploy-preview: |
| 9 | runs-on: ubuntu-latest |
| 10 | steps: |
| 11 | - uses: actions/checkout@v4 |
| 12 | |
| 13 | - name: Deploy to Cloudflare Pages |
| 14 | uses: cloudflare/wrangler-action@v3 |
| 15 | with: |
| 16 | apiToken: $\{{ secrets.CLOUDFLARE_API_TOKEN }} |
| 17 | accountId: $\{{ secrets.CLOUDFLARE_ACCOUNT_ID }} |
| 18 | command: pages deploy dist --branch=pr-$\{{ github.event.pull_request.number }} |
Comment on the PR with the preview URL automatically:
| 1 | - name: Comment PR with preview URL |
| 2 | uses: actions/github-script@v7 |
| 3 | with: |
| 4 | script: | |
| 5 | github.rest.issues.createComment({ |
| 6 | issue_number: context.issue.number, |
| 7 | owner: context.repo.owner, |
| 8 | repo: context.repo.repo, |
| 9 | body: '🚀 Preview deployed: https://pr-$\{{ github.event.pull_request.number }}.my-app.pages.dev' |
| 10 | }) |
Cloudflare Pages provides built-in performance features. Understanding and configuring these correctly maximizes your application's speed.
Automatic Minification
Cloudflare automatically minifies HTML, CSS, and JavaScript at the edge. Enable Brotli compression in the dashboard for 15-20% smaller payloads compared to gzip.
Edge Caching
Set Cache-Control headers on your responses. Cloudflare respects these and caches at the edge. Use stale-while-revalidate for optimal freshness.
Image Optimization
Cloudflare Images and Image Resizing transform images at the edge. Resize, convert formats (WebP, AVIF), and strip metadata on the fly without impacting origin performance.
best practice
Blueprint — Engineering Documentation · Section ID: DEP-CFP-01 · Revision: 1.0