This site — the one you're reading right now — is a Next.js app deployed to Cloudflare Pages. Every push to main ships to production. Every pull request gets its own preview URL, commented directly on the PR.
It's not complicated — just a few moving parts that need to line up.
Why Cloudflare Pages
Cloudflare Pages is a global CDN with instant cache purge on deploy, unlimited bandwidth on the free tier, and preview deployments built in. For a static site there's nothing to manage — you push files and they're served.
The trade-off: Cloudflare Pages doesn't run Node.js at request time, so Next.js features that require a server — API routes, server components that do real-time data fetching, next/image optimisation — need to either be rethought or dropped. For a personal site that's fine. For an app, it might not be.
Step 1 — Configure Next.js for static export
Open next.config.ts and add output: "export":
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
images: {
loader: "custom",
loaderFile: "./src/lib/image-loader.ts",
},
};
export default nextConfig;
output: "export" tells Next.js to write every page as a static HTML file into ./out at build time. No server. No edge runtime. Just files.
The image loader problem
next/image by default proxies images through a server-side optimisation endpoint (/_next/image). That endpoint doesn't exist on a static export, so every <Image /> would 404.
The fix is a custom loader — a small function that tells Next.js how to construct image URLs without server involvement:
// src/lib/image-loader.ts
export default function imageLoader({
src,
width,
quality,
}: {
src: string;
width: number;
quality?: number;
}) {
return `${src}?w=${width}&q=${quality ?? 75}`;
}
This just passes the query params through. No resizing happens server-side — the browser gets the original image. For a photography portfolio that's acceptable; for an image-heavy app you'd want Cloudflare Images or a third-party loader instead.
Build output
npm run build
After the build, ./out contains everything: HTML files for every route, a _next/ folder with JS/CSS chunks, and anything you put in public/. This is the folder you'll deploy.
Step 2 — Create a Cloudflare Pages project
Log into the Cloudflare dashboard, go to Workers & Pages → Pages, and create a new project.
You can connect it to a GitHub repo for Cloudflare's built-in CI, but we're going to skip that and use Direct Upload instead — it gives you full control over the build environment and avoids duplicating logic between Cloudflare's build settings and your Actions workflow.
Name the project — you'll reference this name in the deploy command later. Everything else can stay at defaults.
Get your credentials
Your Account ID is in the Cloudflare dashboard sidebar under your account name (also visible at dash.cloudflare.com/<account-id>).
For the API Token, go to My Profile → API Tokens → Create Token. Use the Edit Cloudflare Workers template, then scope it down to just Pages: Account → Cloudflare Pages → Edit. Copy the token immediately — you won't see it again.
Add secrets to GitHub
In your GitHub repo go to Settings → Secrets and variables → Actions and add:
| Name | Value |
|---|---|
CLOUDFLARE_API_TOKEN | the API token you just created |
CLOUDFLARE_ACCOUNT_ID | your Cloudflare account ID |
These will be available to your workflows as ${{ secrets.CLOUDFLARE_API_TOKEN }} and ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.
Step 3 — Production deploy workflow
Create .github/workflows/deploy.yml:
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./out --project-name=your-project-name --branch=main --commit-dirty=true
environment: production ties the job to a GitHub Actions environment, which lets you add protection rules (required reviewers, wait timers) and keep production-only variables separate from preview variables.
permissions: deployments: write is needed because the wrangler action creates a GitHub deployment record for each push. Without it the action fails silently on repos with restricted default permissions.
--commit-dirty=true suppresses wrangler's refusal to deploy when there are uncommitted files in the working tree. In CI the checkout is always clean, but the flag saves you if you ever run the command manually.
--branch=main tells Cloudflare which deployment is "production". It needs to match the branch you've marked as production in the Cloudflare dashboard.
Step 4 — Preview deploy workflow
Create .github/workflows/preview.yml:
name: Preview Deploy
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
preview:
runs-on: ubuntu-latest
environment: preview
permissions:
contents: read
deployments: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Deploy preview to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
id: deploy
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./out --project-name=your-project-name --branch=${{ github.head_ref }} --commit-dirty=true
- name: Comment preview URL on PR
if: success()
uses: actions/github-script@v9
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `🚀 **Preview deployed:** ${{ steps.deploy.outputs.deployment-url }}`
})
This runs on every PR open, push, and reopen. The structure mirrors the production workflow, with three differences.
--branch=${{ github.head_ref }} deploys to a branch-named URL on Cloudflare Pages (e.g. fix-nav.your-project.pages.dev). Each branch gets its own isolated preview environment.
id: deploy gives the deploy step an ID so the next step can reference its outputs. Without it you can't read steps.deploy.outputs.deployment-url, which is the live URL the wrangler action exposes after a successful deploy.
pull-requests: write is what lets the github-script step post the comment. Easy to forget, and it fails silently if missing.
Step 5 — Environment variables at build time
If your app reads environment variables at build time (e.g. for analytics IDs, feature flags, or API endpoints that get baked into the static output), pass them in the Build step:
- name: Build
run: npm run build
env:
NEXT_PUBLIC_GTM_ID: ${{ vars.NEXT_PUBLIC_GTM_ID }}
SOME_BUILD_SECRET: ${{ secrets.SOME_BUILD_SECRET }}
vars.* are non-secret configuration variables you set in the GitHub environment (Settings → Environments → production/preview → Environment variables). secrets.* are for sensitive values. The split matters because variable values are visible in logs; secret values are masked.
NEXT_PUBLIC_* variables are inlined into the client bundle by Next.js at build time — they're public by design. Anything without that prefix stays server-side and won't appear in the ./out files at all, which means it's useless in a static export unless you need it only during the build itself (e.g. to fetch data).
How it all fits together
git push origin main
└─ deploy.yml triggers
├─ npm ci
├─ npm run build → ./out/
└─ wrangler pages deploy ./out --branch=main
└─ Cloudflare serves ./out globally
└─ GitHub deployment record created ✓
git push origin feature/my-change → open PR
└─ preview.yml triggers
├─ npm ci
├─ npm run build → ./out/
└─ wrangler pages deploy ./out --branch=feature/my-change
├─ Cloudflare serves preview at branch URL
└─ GitHub Actions comments URL on the PR ✓
The only real difference between the two workflows is which branch name goes to Cloudflare and whether the result gets commented on a PR.
Things that will trip you up
Build succeeds locally, fails in CI — the most common cause is environment variables. Run npm run build locally with the variables unset and see if it still passes. Next.js will hard-fail at build time if you reference an undefined NEXT_PUBLIC_* variable.
next/image 404s — you forgot the custom loader, or you're using <img> tags in some places and <Image> in others inconsistently. Check every image component.
Cloudflare serves a stale version — Cloudflare's cache is purged automatically on deploy via the wrangler action. If you see stale content, check whether the deploy step actually ran (look at the Actions run) rather than assuming a cache problem.
Preview URL not posted on the PR — check that the preview environment exists in GitHub and that pull-requests: write is in the permissions block. Both are easy to forget.
--commit-dirty=true failing — this flag suppresses wrangler's clean-tree check, not git errors. If your build generates files that don't get cleaned up and git is unhappy, run git status at the end of the build step to see what's left behind.
The whole setup is about 60 lines of YAML across two files. After that, deployment stops being something you think about.