4 min read
Deploying Next.js to Cloudflare Pages
This site is a Next.js 15 App Router project, statically exported, and deployed to Cloudflare Pages from GitHub Actions. It's the setup I'd recommend for any content-first Next.js site that doesn't need per-request server rendering. Here's how the pieces fit together, and the gotchas I've hit.
Static Export Mode
The first thing to set is output: 'export' in next.config.ts. This tells Next.js to render every route at build time and write plain HTML, CSS, and JS files to out/ instead of running a Node server:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'export',
images: {
unoptimized: true,
},
}
export default nextConfig
images.unoptimized is required because the default next/image loader runs on a server. On a static export there's no server to optimise anything, so the build fails without that flag.
Once this is set, npm run build produces a fully static site. You can open out/index.html in a browser and it works. No runtime is involved anywhere.
What Doesn't Work in Static Export
It's worth being upfront about the trade-offs. A static export can't do:
- Server Actions (no server at runtime)
- Dynamic route handlers (
route.ts) that return different content per request - On-demand ISR or revalidation
What you can do is pre-render route handlers. I use this for sitemap.xml, robots.txt, and feed.xml. The file still lives at app/feed.xml/route.ts, but I mark it static:
export const dynamic = 'force-static'
export async function GET() {
const feed = buildRssFeed()
return new Response(feed, {
headers: { 'Content-Type': 'application/xml' },
})
}
force-static makes Next.js run the handler at build time, capture the response, and write it as a static file. Works with output: 'export'. Any dynamic work you do in the handler has to be build-time-safe (reading the filesystem is fine, calling a runtime database is not).
Static Params for Every Dynamic Route
Every dynamic route under app/ needs a generateStaticParams() that lists every path the build should produce. Forget one and the build fails with a clear message. For a notebook site this typically means two levels:
// app/notebook/[section]/page.tsx
export function generateStaticParams() {
return getAllSections().map((s) => ({ section: s.slug }))
}
// app/notebook/[section]/[slug]/page.tsx
export function generateStaticParams() {
return getAllNotes().map((n) => ({
section: n.section,
slug: n.slug,
}))
}
The build walks the list, renders each combination, and writes the HTML. Adding a new markdown file is a build input change, not a content management problem.
The GitHub Actions Pipeline
The deploy runs on every push to main. The workflow is about fifteen lines:
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- 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 out --project-name=paulund
Two things are worth calling out. fetch-depth: 0 pulls the full git history, which matters if your build uses git log to read file modification dates for articles (this site does). The wrangler-action handles Cloudflare's auth and upload, so there's no bespoke shell script to maintain.
Gotchas Worth Knowing
A few things have bitten me:
- Trailing slashes. Cloudflare Pages serves
/aboutand/about/differently unless you're explicit. SettrailingSlash: trueinnext.config.tsif you want canonical URLs to end in a slash, or leave the default and make sure your sitemap matches what's on disk. - Images in the public folder. These work fine on Pages and don't go through
next/image. Reference them with a plain<img>tag or withnext/imageplusunoptimized. Either way, the build copiespublic/verbatim intoout/. - 404 pages. Cloudflare Pages will serve
out/404.htmlfor unmatched routes automatically, but only if you actually have anot-found.tsxin the App Router. Without one, you get Cloudflare's default 404 page. - Preview deployments. If you want per-branch previews, point the Cloudflare Pages project at your GitHub repo directly instead of uploading via
wrangler. You lose the custom workflow flexibility but get automatic previews. I run the wrangler approach because I want control over the build, and I add a separate preview deploy step for non-main branches when I need it.
Why I Like This Setup
The site is cheap to host, fast everywhere (Cloudflare's edge is global), and there's no runtime to monitor. Every deploy is a fresh upload of static files, so rollback is a single re-run of a previous build. When something breaks, it breaks at build time in CI, not at 3am because a Node process crashed. For a content site, that's the right set of trade-offs.