~/ emre.cavunt_
Cloudflare

Serving a Next.js Static Site on Cloudflare (Like This One)

A Next.js static export on Cloudflare is fast, free, and has no server to patch — the whole setup, MDX to Terraform, in a clonable starter repo.

Files streaming from a browser window into a glowing global edge network
Build to files, serve from the edge — no server in the request path.
TL;DR

This blog is a Next.js static export served from Cloudflare's edge: no server, no runtime cost, nothing to patch at 3am. This is the whole setup — output: 'export', MDX content, security headers, and Terraform for the Cloudflare side — in a clonable starter repo. The catch, stated up front: you give up SSR, ISR, and API routes. For a blog, that's not a catch.

"How do you host this thing for nothing?"

I get asked some version of this often enough that I'd rather point at a repo than retype the answer. The honest reply starts by rejecting the question's hidden assumption — that you need a server.

There are three shapes this usually takes:

  1. A managed host (Vercel, Netlify). Wonderful developer experience, and the right call when you're using server features. For a static blog it's paying — in money or vendor gravity — for a runtime you don't use.
  2. SSR on Cloudflare via OpenNext. The OpenNext adapter runs a real Next.js server on Workers. Genuinely good if you need SSR or ISR on the edge. It is also a server: a runtime with a surface to maintain and reason about.
  3. A static export, served as files. next build writes plain HTML to a folder. Cloudflare hands those files to the browser from a cache near the reader. There is no server in the request path at all.

For a blog — content that changes when I change it, not per request — the third option wins on every axis I care about: it's the fastest (files from the edge beat anything that computes), it's free at this scale, and there's no running process to keep secure. The price is real and worth naming: no API routes, no server-side rendering, no incremental regeneration, no runtime image optimisation. If you need those, pick OpenNext and move on. If you don't, the rest of this is for you.

Everything below is in the starter repo — Next.js 16, Node 24, clone it and pnpm build.

The one line that changes everything

A static export is opt-in, and it's mostly this:

/** @type {import('next').NextConfig} */
const nextConfig = {
  // Emit a fully static site into ./out — no Node server at runtime.
  output: 'export',
 
  // Cloudflare serves files as-is; there is no Next.js image optimizer on the
  // edge, so opt every <Image> into the unoptimized path.
  images: { unoptimized: true },
 
  // Export every route as a directory with its own index.html. This keeps
  // Cloudflare's static routing and the dev server agreeing on trailing slashes.
  trailingSlash: true,
}
 
export default nextConfig
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Emit a fully static site into ./out — no Node server at runtime.
  output: 'export',
 
  // Cloudflare serves files as-is; there is no Next.js image optimizer on the
  // edge, so opt every <Image> into the unoptimized path.
  images: { unoptimized: true },
 
  // Export every route as a directory with its own index.html. This keeps
  // Cloudflare's static routing and the dev server agreeing on trailing slashes.
  trailingSlash: true,
}
 
export default nextConfig

output: 'export' tells Next to render every route at build time and write it to out/. The other two lines aren't decoration — they're the two settings that bite people, which we'll come back to. Run next build and you get a folder of HTML, CSS, and JS. That folder is the website.

Content: the filesystem is the CMS

A blog needs to turn writing into pages. You do not need Contentlayer, a headless CMS, or a database for that — you need a directory and a build step. Use the official @next/mdx integration: it compiles .mdx straight to React at build, and a one-line remark plugin (remark-mdx-frontmatter) re-exports the YAML frontmatter as a plain frontmatter object on each module. No third-party MDX renderer, no separate frontmatter parser.

// next.config.mjs — wire MDX once, with frontmatter support.
import createMDX from '@next/mdx'
 
const withMDX = createMDX({
  options: {
    remarkPlugins: [
      'remark-frontmatter',
      ['remark-mdx-frontmatter', { name: 'frontmatter' }],
    ],
  },
})
 
export default withMDX({ output: 'export', pageExtensions: ['ts', 'tsx', 'mdx'] })
// next.config.mjs — wire MDX once, with frontmatter support.
import createMDX from '@next/mdx'
 
const withMDX = createMDX({
  options: {
    remarkPlugins: [
      'remark-frontmatter',
      ['remark-mdx-frontmatter', { name: 'frontmatter' }],
    ],
  },
})
 
export default withMDX({ output: 'export', pageExtensions: ['ts', 'tsx', 'mdx'] })

Listing posts for the index is then just reading the directory and importing each module's exported frontmatter — no YAML parser in sight:

export async function getAllPosts(): Promise<PostMeta[]> {
  const posts = await Promise.all(
    getPostSlugs().map(async (slug) => {
      const { frontmatter } = await import(`../content/posts/${slug}.mdx`)
      return { slug, ...(frontmatter as Frontmatter) }
    })
  )
  return posts.sort((a, b) => +new Date(b.date) - +new Date(a.date))
}
export async function getAllPosts(): Promise<PostMeta[]> {
  const posts = await Promise.all(
    getPostSlugs().map(async (slug) => {
      const { frontmatter } = await import(`../content/posts/${slug}.mdx`)
      return { slug, ...(frontmatter as Frontmatter) }
    })
  )
  return posts.sort((a, b) => +new Date(b.date) - +new Date(a.date))
}

The dynamic route pre-renders one page per file, renders the module's default export, and refuses anything else — dynamicParams = false is what keeps a static export honest:

export const dynamicParams = false
 
export function generateStaticParams() {
  return getPostSlugs().map((slug) => ({ slug }))
}
 
export default async function PostPage({ params }) {
  const { slug } = await params
  const { default: Post, frontmatter } = await import(`../../../content/posts/${slug}.mdx`)
  return <article><h1>{frontmatter.title}</h1><Post /></article>
}
export const dynamicParams = false
 
export function generateStaticParams() {
  return getPostSlugs().map((slug) => ({ slug }))
}
 
export default async function PostPage({ params }) {
  const { slug } = await params
  const { default: Post, frontmatter } = await import(`../../../content/posts/${slug}.mdx`)
  return <article><h1>{frontmatter.title}</h1><Post /></article>
}

Add content/posts/my-post.mdx, and out/blog/my-post/index.html appears on the next build, listed automatically. No admin panel, no migration, no moving parts. The content model is ls content/posts.

Headers: the part the tutorials skip

Here's where most "deploy Next to Cloudflare" guides go quiet. A static export has no server, so where do your security headers come from? You can't set them in next.config.js — that only applies to a running Next server you don't have.

Cloudflare Pages reads a _headers file from your output. That file is your header layer:

/*
  Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'
  Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=()

/_next/static/*
  Cache-Control: public, max-age=31536000, immutable
/*
  Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'
  Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=()

/_next/static/*
  Cache-Control: public, max-age=31536000, immutable

Put it in public/ and Next copies it into out/ verbatim. Redirects work the same way via a _redirects file. This is the detail that separates a site that works from one that passes a security review — a static site with no CSP is a static site one injected script away from trouble.

Terraform owns the platform; Wrangler owns the bits

There are two distinct jobs here, and conflating them is the usual source of confusion. Terraform provisions the Cloudflare platform — the Pages project, the custom domain, the DNS. Wrangler ships the files. Don't expect terraform apply to upload your HTML; that's not its job.

resource "cloudflare_pages_project" "site" {
  account_id        = var.cloudflare_account_id
  name              = var.project_name
  production_branch = var.production_branch
}
 
resource "cloudflare_pages_domain" "custom" {
  account_id   = var.cloudflare_account_id
  project_name = cloudflare_pages_project.site.name
  name         = var.domain
}
 
resource "cloudflare_dns_record" "site" {
  zone_id = var.cloudflare_zone_id
  name    = var.domain
  type    = "CNAME"
  content = "${cloudflare_pages_project.site.name}.pages.dev"
  ttl     = 1
  proxied = true
}
resource "cloudflare_pages_project" "site" {
  account_id        = var.cloudflare_account_id
  name              = var.project_name
  production_branch = var.production_branch
}
 
resource "cloudflare_pages_domain" "custom" {
  account_id   = var.cloudflare_account_id
  project_name = cloudflare_pages_project.site.name
  name         = var.domain
}
 
resource "cloudflare_dns_record" "site" {
  zone_id = var.cloudflare_zone_id
  name    = var.domain
  type    = "CNAME"
  content = "${cloudflare_pages_project.site.name}.pages.dev"
  ttl     = 1
  proxied = true
}

A note for anyone with older Terraform lying around: the Cloudflare provider v5 renamed cloudflare_record to cloudflare_dns_record and switched the record's value field to content. If you're copying a two-year-old snippet, that's why it won't apply.

The API token never appears here. The provider reads CLOUDFLARE_API_TOKEN from the environment, so it stays out of state and out of the repo. It needs two scopes: Pages › Edit and DNS › Edit on the zone.

Deploying the actual site is one command:

export CLOUDFLARE_API_TOKEN="..."
pnpm deploy   # next build && wrangler pages deploy out
export CLOUDFLARE_API_TOKEN="..."
pnpm deploy   # next build && wrangler pages deploy out

CI, two ways

The simple workflow keeps the Cloudflare token as a GitHub secret and deploys on push:

      - run: pnpm build
      - name: Deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy out --project-name=nextjs-cloudflare-static-starter
      - run: pnpm build
      - name: Deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy out --project-name=nextjs-cloudflare-static-starter

That's fine, and for a personal project it's where I'd stop. But a long-lived API token sitting in your CI provider is exactly the kind of credential that leaks. The grown-up version stores no Cloudflare token in GitHub at all: authenticate to GCP via Workload Identity Federation, pull the token from Secret Manager at run time, hand it to Wrangler.

      - name: Authenticate to GCP (no key)
        uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
          service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
 
      - name: Pull the Cloudflare token from Secret Manager
        id: secrets
        uses: google-github-actions/get-secretmanager-secrets@v3
        with:
          secrets: |-
            cloudflare_token:${{ vars.GCP_PROJECT_ID }}/cloudflare-api-token
 
      - name: Deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ steps.secrets.outputs.cloudflare_token }}
          accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy out --project-name=nextjs-cloudflare-static-starter
      - name: Authenticate to GCP (no key)
        uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
          service_account: ${{ vars.GCP_SERVICE_ACCOUNT }}
 
      - name: Pull the Cloudflare token from Secret Manager
        id: secrets
        uses: google-github-actions/get-secretmanager-secrets@v3
        with:
          secrets: |-
            cloudflare_token:${{ vars.GCP_PROJECT_ID }}/cloudflare-api-token
 
      - name: Deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ steps.secrets.outputs.cloudflare_token }}
          accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy out --project-name=nextjs-cloudflare-static-starter

I wrote up that whole pattern — including the part where you scope the service account to read only that one secret, not your entire project — in keyless GCP secrets in GitHub Actions. The starter repo ships both workflows so you can start simple and graduate.

The foot-guns, enumerated

output: 'export' removes the server, and a few things go with it. Better to know them now than to debug them on a Friday:

  • Images. No edge optimiser exists, so images: { unoptimized: true } is mandatory. Forget it and the build fails the moment you use next/image.
  • Dynamic routes need generateStaticParams. There's nothing to render /blog/[slug] on demand. Enumerate the params at build, set dynamicParams = false, and unknown paths 404 like static sites should.
  • No API routes, middleware, or server actions. They need a runtime you deleted. If a feature reaches for one, it's a sign this isn't the right shape for that feature.
  • Trailing slashes. Cloudflare serves /blog/post/ from blog/post/index.html. Set trailingSlash: true so your dev server and your deployed site agree; mismatches show up as redirects or 404s only in production, which is the worst place to find them.
  • 404s. Next emits a static 404.html; Cloudflare Pages serves it automatically. Don't reach for SPA-style catch-all routing — you have real pages, not an app shell.

An honest aside: this blog doesn't do exactly this

Full disclosure, because it matters. The repo uses the App Router with the official @next/mdx. This blog you're reading still runs the Pages Router with Contentlayer. Why the difference?

Inertia and features, mostly. This site predates a few things, and Contentlayer gives me typed content and computed fields I lean on. But Contentlayer is heavy and effectively unmaintained, and if I were starting today I'd build exactly what's in the starter.

A word on the obvious alternative: next-mdx-remote is the package most blog tutorials reach for, and I nearly did too. Then I checked — its repository is archived. That's the trap with MDX tooling: the ergonomic third-party option is often the one that's quietly stopped shipping. @next/mdx is maintained by the Next.js team and versioned in lockstep with Next itself, which is exactly the property you want for the layer that turns your writing into your site. Pick the boring official integration; it'll still be here next year. The starter is the advice; this blog is the legacy I haven't paid down yet. Both export to static, both serve from Cloudflare the same way, which is the part that matters here.

Conclusion

A blog is content plus delivery. The content is a folder of MDX. The delivery is files on a CDN. Everything between those two facts — the server, the runtime, the per-request compute — is overhead a blog doesn't need and shouldn't pay for. Pick the boring, static shape, ship your security headers in a _headers file, let Terraform own the platform and Wrangler own the bits, and you get a site that's faster than most, costs nothing, and has no 3am pager attached to it.

Clone the starter and make it yours.

References