
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:
- 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.
- 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.
- A static export, served as files.
next buildwrites 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 nextConfigoutput: '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, immutablePut 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 outexport CLOUDFLARE_API_TOKEN="..."
pnpm deploy # next build && wrangler pages deploy outCI, 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-starterThat'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-starterI 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 usenext/image. - Dynamic routes need
generateStaticParams. There's nothing to render/blog/[slug]on demand. Enumerate the params at build, setdynamicParams = 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/fromblog/post/index.html. SettrailingSlash: trueso 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.