{"slug": "making-dynamic-mdx-blogs-work-with-opennext-on-cloudflare-workers", "title": "Making Dynamic MDX Blogs Work with OpenNext on Cloudflare Workers", "summary": "Problem where a Next.js MDX blog works locally but displays empty pages when deployed to Cloudflare Workers using OpenNext, due to the incompatibility of runtime filesystem reads (`node:fs`) with the Worker's bundled environment. The solution involves moving blog file discovery and metadata parsing from runtime to build time, generating static TypeScript files and import statements that the Worker can use without accessing the filesystem during requests. This approach maintains the file-based authoring workflow while ensuring reliable deployment on Cloudflare Workers.", "body_md": "I ran into a small but annoying problem while deploying a Next.js MDX blog to Cloudflare Workers with OpenNext.\n\nThe blog worked locally. The build passed. OpenNext even listed the blog routes during the build.\n\nThen I opened the deployed site, and the blog page was empty.\n\nThe issue was not MDX. It was not frontmatter. It was not a missing route. The real problem was that my blog code was still thinking like a normal Node.js app, while Cloudflare Workers runs from a bundled Worker output.\n\n## The Short Version\n\nIf your MDX blog works in `next dev`\n\nbut shows empty pages or missing posts on Cloudflare Workers, check if you read blog files with `node:fs`\n\nat request time.\n\nThis is the safer pattern:\n\n- Keep writing posts as\n`.mdx`\n\nfiles. - Parse the files during the build.\n- Generate a small TypeScript file with post metadata.\n- Generate another TypeScript file that statically imports every MDX post.\n- Render posts from that generated registry in production.\n\nThat way the Worker does not need to scan your `content/blog`\n\nfolder at runtime.\n\n## What Broke\n\nThe old setup looked something like this:\n\n``` python\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport matter from \"gray-matter\";\n\nconst postsDir = path.join(process.cwd(), \"content\", \"blog\");\n\nexport function getAllPosts() {\n  return fs.readdirSync(postsDir).map((file) => {\n    const raw = fs.readFileSync(path.join(postsDir, file), \"utf8\");\n    const { data } = matter(raw);\n\n    return {\n      slug: file.replace(/\\.mdx$/, \"\"),\n      title: data.title,\n      description: data.description,\n    };\n  });\n}\n```\n\nThis feels fine in local development because the source files are right there on disk.\n\nBut after OpenNext builds the app for Cloudflare Workers, the app is running from a Worker bundle. Cloudflare does support `node:fs`\n\nthrough a virtual filesystem, but that filesystem is not the same as having your project folder mounted in production. Files inside the Worker bundle are readable under `/bundle`\n\n, and `/tmp`\n\nis temporary for a request.\n\nSo I stopped treating the source folder as runtime data.\n\n## The Fix\n\nI moved blog discovery to build time.\n\nThe build step reads the MDX files once, parses the frontmatter, and writes generated TypeScript files that the app can import normally.\n\nThe mental model is simple:\n\n```\nAuthoring:\n  content/blog/*.mdx\n\nBuild:\n  read MDX files\n  parse frontmatter\n  generate metadata\n  generate static imports\n\nRuntime:\n  import generated modules\n  render the matching MDX component\n```\n\nThis keeps the nice file-based writing flow, but removes runtime filesystem reads from the deployed Worker.\n\n### Step 1: Generate Blog Metadata\n\nI use a script before the build. It reads the `.mdx`\n\nfiles and creates metadata for the blog index, sitemap, RSS feed, and page metadata.\n\n``` python\n// scripts/generate-blog-data.mjs\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport matter from \"gray-matter\";\n\nconst root = process.cwd();\nconst postsDir = path.join(root, \"content\", \"blog\");\nconst outputDir = path.join(root, \"src\", \"lib\", \"blog\");\n\nconst files = fs.readdirSync(postsDir).filter((file) => file.endsWith(\".mdx\"));\n\nconst posts = files.map((file) => {\n  const slug = file.replace(/\\.mdx$/, \"\");\n  const raw = fs.readFileSync(path.join(postsDir, file), \"utf8\");\n  const { data, content } = matter(raw);\n\n  const words = content.trim().split(/\\s+/).filter(Boolean).length;\n\n  return {\n    slug,\n    title: data.title,\n    description: data.description,\n    date: data.date,\n    readingTime: `${Math.max(1, Math.ceil(words / 225))} min read`,\n  };\n});\n\nfs.mkdirSync(outputDir, { recursive: true });\n\nfs.writeFileSync(\n  path.join(outputDir, \"generated-posts.ts\"),\n  `export const allBlogPosts = ${JSON.stringify(posts, null, 2)} as const;\\n`\n);\n```\n\nThe important part is not the exact script. The important part is when it runs.\n\nIt runs before `next build`\n\n, not when someone opens `/blog`\n\n.\n\n### Step 2: Generate Static MDX Imports\n\nThe next file is the one that makes the Worker build reliable.\n\nInstead of doing this:\n\n```\nawait import(`../../../content/blog/${slug}.mdx`);\n```\n\nI generate static imports:\n\n``` python\n// src/lib/blog/generated-components.ts\nimport type { ComponentType } from \"react\";\n\nimport Post0 from \"../../../content/blog/first-post.mdx\";\nimport Post1 from \"../../../content/blog/second-post.mdx\";\n\nconst blogPostComponents: Record<string, ComponentType> = {\n  \"first-post\": Post0,\n  \"second-post\": Post1,\n};\n\nexport function getPostComponent(slug: string) {\n  return blogPostComponents[slug] ?? null;\n}\n```\n\nThis gives Next.js and OpenNext a clear import graph. They can see the MDX files, compile them, and include them in the Worker output.\n\nThat is much easier to trust than a variable import path.\n\n### Step 3: Render From the Registry\n\nThe blog route becomes a lookup, not a file scan.\n\n``` js\nimport { notFound } from \"next/navigation\";\nimport { getPostComponent } from \"@/lib/blog/generated-components\";\nimport { allBlogPosts } from \"@/lib/blog/generated-posts\";\n\nexport function generateStaticParams() {\n  return allBlogPosts.map((post) => ({ slug: post.slug }));\n}\n\nexport default async function BlogPostPage({ params }) {\n  const { slug } = await params;\n  const post = allBlogPosts.find((item) => item.slug === slug);\n  const PostContent = getPostComponent(slug);\n\n  if (!post || !PostContent) {\n    notFound();\n  }\n\n  return (\n    <article>\n      <h1>{post.title}</h1>\n      <p>{post.description}</p>\n      <PostContent />\n    </article>\n  );\n}\n```\n\nNow the production route only depends on bundled code.\n\nNo `fs.readdirSync`\n\n. No `process.cwd()`\n\n. No runtime `gray-matter`\n\n.\n\n### Step 4: Run the Script Before Every Build\n\nI wired the generator into the build scripts:\n\n```\n{\n  \"scripts\": {\n    \"prebuild\": \"node scripts/generate-blog-data.mjs\",\n    \"build\": \"next build\",\n    \"prebuild:cf\": \"node scripts/generate-blog-data.mjs\",\n    \"build:cf\": \"opennextjs-cloudflare build -c wrangler.jsonc\"\n  }\n}\n```\n\nNow when I add a new `.mdx`\n\nfile, the next build updates the generated metadata and component registry.\n\nI still get the same writing flow:\n\n```\ncontent/blog/my-new-post.mdx\n```\n\nBut the deployed Worker gets predictable imports.\n\n### Step 5: Test the Worker Build\n\nI do not stop at `next build`\n\nfor this kind of bug.\n\n`next build`\n\ncan pass while the Worker output still behaves differently. So I check the Cloudflare build too:\n\n```\npnpm build:cf\n```\n\nThen I preview the Worker locally:\n\n```\npnpm exec opennextjs-cloudflare preview -c wrangler.jsonc\n```\n\nAnd I test three routes:\n\n```\ncurl -I http://localhost:8787/blog\ncurl -I http://localhost:8787/blog/my-real-post\ncurl -I http://localhost:8787/blog/not-a-real-post\n```\n\nThe result I want:\n\n```\n/blog                 200\n/blog/my-real-post    200\n/blog/not-a-real-post 404\n```\n\nThe fake post matters. A working blog should render real posts and still reject bad slugs.\n\n#### Quick Debug Checklist\n\n- Does any blog route import\n`node:fs`\n\n? - Does\n`/blog`\n\ncall`fs.readdirSync`\n\nduring a request? - Does the post page use a variable MDX import path?\n- Does the generated registry include every\n`.mdx`\n\nfile? - Does\n`pnpm build:cf`\n\nlist the expected blog routes? - Does the local Worker preview return\n`200`\n\nfor a real post? - Does it return\n`404`\n\nfor a fake post?\n\n## Where the Content Lives\n\nThe full post content still lives in MDX files.\n\nThe generated metadata file only stores things like:\n\n- slug\n- title\n- description\n- date\n- reading time\n- headings, if you need a table of contents\n\nI do not put the whole article body into JSON. That gets messy fast, especially with code blocks, custom MDX components, and imports.\n\nInstead, the body is compiled from the MDX module:\n\n``` python\ncontent/blog/my-post.mdx\n        |\n        v\nimport Post from \"../../../content/blog/my-post.mdx\"\n        |\n        v\n<Post />\n```\n\nThat is the clean split:\n\n- metadata is generated as plain data\n- content is rendered as compiled MDX\n\n## A Small SEO Note\n\nThe technical fix gets the pages to render. SEO still depends on whether the page is useful.\n\nFor this kind of technical post, I try to keep the basics simple:\n\n- use a clear title\n- describe the problem in the first few lines\n- use short sections\n- write headings that say what the section is about\n- add examples that someone can copy into their project\n- link to the official docs when they matter\n- avoid padding the post to hit a word count\n\nThat last point is important. Google does not require a magic word count. A shorter post that solves the problem is better than a long post that makes the reader dig.\n\n## Final Thought\n\nThe fix was mostly a change in where the work happens.\n\nBefore, the Worker had to discover blog files at request time.\n\nAfter, the build discovered the files once, generated a registry, and gave the Worker normal imports to render.\n\nThat made the blog simple again. I can still add posts as `.mdx`\n\nfiles, but production no longer depends on reading my source folder at runtime.", "url": "https://wpnews.pro/news/making-dynamic-mdx-blogs-work-with-opennext-on-cloudflare-workers", "canonical_source": "https://dev.to/harshalranjhani/making-dynamic-mdx-blogs-work-with-opennext-on-cloudflare-workers-b5o", "published_at": "2026-05-22 08:07:41+00:00", "updated_at": "2026-05-22 08:21:42.518666+00:00", "lang": "en", "topics": ["developer-tools", "cloud-computing"], "entities": ["OpenNext", "Cloudflare Workers", "Next.js", "MDX", "Node.js"], "alternates": {"html": "https://wpnews.pro/news/making-dynamic-mdx-blogs-work-with-opennext-on-cloudflare-workers", "markdown": "https://wpnews.pro/news/making-dynamic-mdx-blogs-work-with-opennext-on-cloudflare-workers.md", "text": "https://wpnews.pro/news/making-dynamic-mdx-blogs-work-with-opennext-on-cloudflare-workers.txt", "jsonld": "https://wpnews.pro/news/making-dynamic-mdx-blogs-work-with-opennext-on-cloudflare-workers.jsonld"}}