Moving a Next.js Blog to Cloudflare Workers: The Upgrade Was Easy, the Runtime Boundary Was Not
The dependency upgrade was the quick part. The real work was deciding what belongs at build time and what must never run inside a Cloudflare Worker request.
The Short Version
This migration looked like a stack upgrade at first: Node.js 22.22.2, pnpm 11, Next.js 16, React 19, strict TypeScript, Biome, Zod, OpenNext, Wrangler, and a Cloudflare Workers deployment.
The version bump was not the hard part.
The real lesson was about runtime boundaries:
- A Cloudflare Worker request should not expect the original
content/postsdirectory to exist. - A Cloudflare Worker request cannot use MDX tooling that calls
new Functionunder the hood.
Both problems pushed the project toward the same shape: discover, validate, and compile content at build time. At runtime, the Worker should read prepared data from the bundle and render it.
That sounds obvious after the fact. It did not feel obvious while chasing 500s.
The Upgrade Itself Was Pretty Calm
The project now states the runtime and package manager directly:
{
"engines": {
"node": ">=22.22.2"
},
"packageManager": "pnpm@11.0.9"
}
The main stack moved to:
{
"next": "16.2.6",
"react": "19.2.6",
"react-dom": "19.2.6",
"typescript": "^6.0.3",
"@biomejs/biome": "2.4.14",
"zod": "^4.4.3"
}
TypeScript stayed strict, and Biome became the main lint entry:
{
"scripts": {
"lint": "biome check .",
"typecheck": "tsc --noEmit"
}
}
None of this is flashy. But it matters. Once deployment issues start showing up, you want fewer moving parts: one Node version, one package manager, one lockfile, one lint command, and strict type checks that keep the boring stuff honest.
First Failure: Wrangler Tried to Configure the Project During Deploy
The first Cloudflare build was a little deceptive. next build finished successfully, and then npx wrangler deploy detected a Next.js project and tried to set things up interactively.
That is fine on a laptop. It is not fine in a non-interactive build environment.
pnpm 11 also made the failure more visible:
ERR_PNPM_IGNORED_BUILDS Ignored build scripts: workerd
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
The fix was not to make the deploy step smarter. The fix was to make it less responsible.
The project now has an explicit wrangler.jsonc:
{
"name": "hopes",
"main": ".open-next/worker.js",
"compatibility_date": "2026-05-09",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"binding": "ASSETS",
"directory": ".open-next/assets"
},
"observability": {
"logs": {
"enabled": true,
"invocation_logs": true
},
"traces": {
"enabled": false
}
}
}
And a small OpenNext config:
import { defineCloudflareConfig, type OpenNextConfig } from '@opennextjs/cloudflare'
export default {
...defineCloudflareConfig(),
buildCommand: 'pnpm next:build',
} satisfies OpenNextConfig
The deployment pipeline became more explicit: the project builds itself, OpenNext produces the Worker output, and Wrangler deploys that output.
Second Failure: The Worker Could Not Read content/posts
After the deploy config was fixed, the site still failed at runtime:
no such file or directory, readdir '/bundle/content/posts'
That error was useful because it was blunt. The app was still trying to scan the source content directory during a Worker request.
That works in local Node. It does not work once the app has been bundled for Workers.
The content discovery step moved into a build script:
const files = (await fs.readdir(postsDir))
.filter(filename => filename.endsWith('.md') || filename.endsWith('.mdx'))
.sort((a, b) => a.localeCompare(b))
The script parses each MDX file and writes a generated index:
lib/generated/content-index.json
The runtime then imports that generated file:
import generatedContentPosts from './generated/content-index.json'
This changed the mental model of the content system. The source directory is a build input, not a runtime dependency.
Third Failure: MDX Compilation Used new Function
Fixing the content directory issue made the blog list work, but article pages were still returning 500.
Cloudflare’s logs had the important line:
Code generation from strings disallowed for this context
at new Function (<anonymous>)
at compileMDX
This one was easier to miss because local development worked. The normal Next.js build also worked. The failure only appeared inside the Worker runtime.
The article page was still calling:
compileMDX(...)
compileMDX is convenient, but in this deployment target it was the wrong place to do the work. Cloudflare Workers does not allow the kind of dynamic code generation used by that path.
So the content generation step grew one more responsibility. It now turns Markdown/MDX content into HTML during the build:
const markdownProcessor = remark()
.use(remarkGfm)
.use(remarkToc, { heading: 'h2' })
.use(remarkRehype)
.use(rehypeSlug)
.use(rehypeHighlight)
.use(rehypeStringify)
Article pages render contentHtml from the generated index instead of compiling MDX during the request.
I also removed next-mdx-remote after that. Keeping an unused runtime compiler around would make it too easy to reintroduce the same issue later.
Frontmatter Also Needed a Small Contract
Several MDX posts were missing publishedAt, so the content schema and BLOG.md needed to agree on what a valid post looks like.
The important rules are simple:
- Always include
publishedAt. - Prefer a string date like
"2026-05-09". - Use
"zh"or"en"forlocale. - Keep
categoriesandtagsas arrays. - Prefer filenames like
<slug>.<locale>.mdx.
This is not just documentation polish. A missing metadata field can quietly turn into a missing post in the list, which is exactly the kind of small content bug that wastes too much time later.
What I Verified
The final checks were:
pnpm lint
pnpm typecheck
pnpm build
npx wrangler deploy --dry-run
I also tested the OpenNext Worker preview directly against:
/zh/blog
/zh/blog/multica-shared-human-ai-board
/en/blog/multica-shared-human-ai-board
The important part was not just getting a 200. It was making sure the Worker no longer hit:
Code generation from strings disallowed for this context
That is the difference between “works in Next dev” and “works where it is actually deployed.”
What I Would Remember Next Time
Cloudflare Workers is not a regular Node server. That is easy to say, but migrations make you feel it.
For a content-heavy blog, I would now default to this shape:
- Scan posts at build time.
- Validate frontmatter at build time.
- Compile Markdown/MDX at build time.
- Keep runtime rendering as small and predictable as possible.
There is one trade-off: you lose some runtime flexibility. For this kind of site, that is fine. A blog does not need to compile every article on every request. It needs to be reliable, cacheable, and boring in the best possible way.
One last detail surprised me a little: after moving from MDX React components to generated HTML, the typography needed attention. The old component-level Tailwind classes did not magically follow the new renderer. The .prose styles had to carry that responsibility.
That is a good reminder. Shipping the page is not the same as making it feel right. For a blog, the reading experience is part of the system too.