MDX on Cloudflare Workers with OpenNext: Three Bugs, Three Fixes

How I debugged and fixed MDX-powered blog pages that worked locally but returned 404 on Cloudflare Workers with @opennextjs/cloudflare.

Harry Tran
7 min read · 1250 words

I spent a debugging session tracking down why my MDX blog worked perfectly on next dev but returned empty lists and 404s after deploying to Cloudflare Workers with @opennextjs/cloudflare. There were three separate bugs hiding behind one symptom. This post documents all of them.

#The Setup

A Next.js 16 app deployed to Cloudflare Workers via @opennextjs/cloudflare. Blog posts are written in MDX files stored in src/content/posts/. Nothing exotic — but none of the blog routes worked in production.

src/
├── content/posts/
│ ├── my-first-post.mdx
│ └── second-post.mdx
├── app/
│ └── blog/
│ ├── page.tsx ← listing page
│ └── [slug]/
│ └── page.tsx ← post detail page
└── lib/
└── posts.ts ← reads .mdx files

#Bug #1 — fs is not the filesystem you think it is

The posts.ts library used Node's fs module to read MDX files at runtime:

typescript
// ❌ This does NOT work in Cloudflare Workers
import fs from 'fs'
import path from 'path'
const postsDirectory = path.join(process.cwd(), 'src/content/posts')
export function getAllPostSlugs(): string[] {
const fileNames = fs.readdirSync(postsDirectory) // ← fails at runtime
return fileNames
.filter(fileName => fileName.endsWith('.mdx'))
.map(fileName => fileName.replace(/\.mdx$/, ''))
}
function getRawContent(slug: string): string | null {
const fullPath = path.join(postsDirectory, `${slug}.mdx`)
return fs.readFileSync(fullPath, 'utf8') // ← fails at runtime
}

Cloudflare Workers run in an isolated V8 environment. Even with nodejs_compat in wrangler.jsonc, fs.readdirSync and fs.readFileSync cannot access your source files at request time — those files don't exist in the deployed worker bundle.

The fix: pre-generate a JSON manifest at build time and import it statically. A Node.js script (scripts/generate-posts-manifest.mjs) reads every .mdx file during the build and writes a single JSON file:

javascript
// scripts/generate-posts-manifest.mjs
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const postsDir = path.join(__dirname, '..', 'src', 'content', 'posts')
const outputDir = path.join(__dirname, '..', 'src', 'generated')
const files = fs.readdirSync(postsDir).filter(f => f.endsWith('.mdx'))
const manifest = {}
for (const file of files) {
const slug = file.replace(/\.mdx$/, '')
manifest[slug] = fs.readFileSync(path.join(postsDir, file), 'utf8')
}
fs.mkdirSync(outputDir, { recursive: true })
fs.writeFileSync(path.join(outputDir, 'posts-manifest.json'), JSON.stringify(manifest))
console.log(`✓ Generated posts manifest: ${files.length} posts`)

Then posts.ts imports the JSON statically — the bundler inlines it into the worker:

typescript
// ✅ Static JSON import — bundled at build time, no fs needed
import postsManifest from '@/generated/posts-manifest.json'
const manifest = postsManifest as Record<string, string>
export function getAllPostSlugs(): string[] {
return Object.keys(manifest)
}
function getRawContent(slug: string): string | null {
return manifest[slug] ?? null
}

gray-matter, zod validation, and everything else stays the same — only the data source changes.

Run the manifest generator before every build:

json
{
"scripts": {
"dev": "node scripts/generate-posts-manifest.mjs && next dev",
"build": "node scripts/generate-posts-manifest.mjs && next build",
"deploy": "node scripts/generate-posts-manifest.mjs && opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"preview": "node scripts/generate-posts-manifest.mjs && opennextjs-cloudflare build && opennextjs-cloudflare preview"
}
}

#Bug #2 — Dynamic template literal imports don't bundle

The original blog post page imported MDX files like this:

typescript
// ❌ esbuild cannot statically analyze a variable path
const { default: PostContent } = await import(
`@/content/posts/${slug}.mdx`
)

This worked in next dev because the Next.js dev server handles dynamic imports at runtime. In Cloudflare Workers, esbuild bundles everything at build time and cannot resolve a template literal with a variable — so the MDX components are never included in the bundle.

The fix: use next-mdx-remote/rsc. The MDXRemote RSC component takes a raw MDX string and compiles it server-side. Since post.content is already the MDX body string (parsed from the manifest by gray-matter), it slots in directly:

typescript
// ✅ No dynamic import — MDXRemote takes a string, compiles at build time
import { MDXRemote } from 'next-mdx-remote/rsc'
import remarkGfm from 'remark-gfm'
import rehypeSlug from 'rehype-slug'
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const { slug } = await params
const post = getPostBySlug(slug)
if (!post) notFound()
return (
<BlogLayout>
<MDXRemote
source={post.content}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeSlug],
},
}}
/>
</BlogLayout>
)
}

@next/mdx and its loader packages (@mdx-js/loader, @mdx-js/react) are no longer needed and can be removed from package.json. The next.config.ts also simplifies — no more withMDX wrapper:

typescript
// next.config.ts — before
import createMDX from '@next/mdx'
const withMDX = createMDX({ ... })
export default withMDX(nextConfig)
// next.config.ts — after
export default nextConfig // no MDX wrapper needed

#Bug #3 — The incremental cache was silently discarded

After fixes #1 and #2, the blog listing page at /blog started working. But every individual post (/blog/[slug]) and tag page (/blog/tags/[tag]) still returned 404, despite the build output showing all pages pre-rendered:

● /blog/[slug]
│ ├ /blog/advanced-playwright-patterns-pom-fixtures-architecture
│ ├ /blog/getting-started-with-nextjs-and-mdx
│ └ [+5 more paths]

This was the most subtle bug. Tracing through .open-next/server-functions/default/open-next.config.mjs revealed the problem:

javascript
function resolveIncrementalCache(value = "dummy") {
// ...
}

The default incrementalCache is "dummy" — a no-op implementation. When opennextjs-cloudflare preview runs, the populateCache step checks which cache backend is configured and hits the default branch:

javascript
switch (name) {
case STATIC_ASSETS_CACHE_NAME:
populateStaticAssetsIncrementalCache(buildOpts) // copies cache to assets
break
default:
logger.info("Incremental cache does not need populating") // ← does nothing
}

"Incremental cache does not need populating" sounds harmless. What it actually means: the 42 pre-rendered .cache files in .open-next/cache/ are never copied anywhere the worker can read them. The worker looks for SSG pages in the ASSETS binding, finds nothing, and returns 404.

Pages marked as ○ (Static) — like the /blog listing with export const dynamic = 'force-static' — work fine because Next.js handles them differently. Pages marked ● (SSG) — all generateStaticParams routes — need the incremental cache.

The fix: configure staticAssetsIncrementalCache in open-next.config.ts. This copies all pre-rendered cache files into .open-next/assets/cdn-cgi/_next_cache/ on every preview/deploy/upload, where the ASSETS binding can serve them:

typescript
// open-next.config.ts
import { defineCloudflareConfig } from '@opennextjs/cloudflare'
import staticAssetsIncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache'
export default defineCloudflareConfig({
incrementalCache: staticAssetsIncrementalCache,
})

After this change, the preview log changes from the silent no-op to:

Populating Workers static assets...
Successfully populated static assets cache

And the directory structure confirms all 42 cache files are now accessible:

.open-next/assets/
└── cdn-cgi/
└── _next_cache/
└── {buildId}/
├── blog/
│ ├── advanced-playwright-patterns-pom-fixtures-architecture.cache
│ ├── getting-started-with-nextjs-and-mdx.cache
│ └── tags/
│ ├── playwright.cache
│ └── testing.cache
└── index.cache

#The Full Picture

Three completely independent bugs, one visible symptom:

SymptomRoot causeFix
Blog listing emptyfs.readdirSync fails at runtime in WorkersPre-generate posts-manifest.json, import statically
Dynamic import failsimport(`.../${slug}.mdx`) not bundleableReplace with next-mdx-remote/rsc and a string source
SSG pages 404incrementalCache: "dummy" never populates ASSETSUse staticAssetsIncrementalCache in open-next.config.ts

The staticAssetsIncrementalCache fix is the most important to know about. Nothing in the default OpenNext template warns you about it, the log message actively sounds like success, and it silently breaks every route generated by generateStaticParams.

If you deploy a Next.js blog to Cloudflare Workers with OpenNext and your post pages 404 while your listing page works — this is almost certainly your issue.


#Summary of Changes

open-next.config.ts — configure static assets incremental cache.

next.config.ts — remove @next/mdx / withMDX wrapper entirely.

src/lib/posts.ts — replace all fs calls with a static JSON import.

src/app/blog/[slug]/page.tsx — replace dynamic MDX import with <MDXRemote source={post.content} />, add export const dynamic = 'force-static'.

scripts/generate-posts-manifest.mjs — run before every build to pre-bundle MDX content.

package.json — prefix every build/deploy script with the manifest generator.