Skip to main content
paulund

4 min read

#nextjs#cloudflare#deployment#static-export

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 /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.

Related notes


Newsletter

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