Next.js

Deploying Next.js to Cloudflare Pages

How I deploy a statically exported Next.js 15 site to Cloudflare Pages with GitHub Actions, plus the gotchas around output mode, images, and route handlers.

On this page
  1. Static Export Mode
  2. What Doesn't Work in Static Export
  3. Static Params for Every Dynamic Route
  4. The GitHub Actions Pipeline
  5. Gotchas Worth Knowing
  6. Why I Like This Setup

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 /about and /about/ differently unless you're explicit. Set trailingSlash: true in next.config.ts if 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 with next/image plus unoptimized. Either way, the build copies public/ verbatim into out/.
  • 404 pages. Cloudflare Pages will serve out/404.html for unmatched routes automatically, but only if you actually have a not-found.tsx in 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.

← Older
Dynamic OG Images in Next.js with opengraph-image.tsx
Newer →
Client-side validation before Server Actions in Next.js

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.