Command Palette

Search for a command to run...

The Short Version

Making Dynamic MDX Blogs Work with OpenNext on Cloudflare Workers

A simple way to fix MDX blog pages that work locally but render empty on Cloudflare Workers with OpenNext.

AI SummaryGet a quick overview

I ran into a small but annoying problem while deploying a Next.js MDX blog to Cloudflare Workers with OpenNext.

The blog worked locally. The build passed. OpenNext even listed the blog routes during the build.

Then I opened the deployed site, and the blog page was empty.

The 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.

The Short Version

If your MDX blog works in next dev but shows empty pages or missing posts on Cloudflare Workers, check if you read blog files with node:fs at request time.

This is the safer pattern:

  • Keep writing posts as .mdx files.
  • Parse the files during the build.
  • Generate a small TypeScript file with post metadata.
  • Generate another TypeScript file that statically imports every MDX post.
  • Render posts from that generated registry in production.

That way the Worker does not need to scan your content/blog folder at runtime.

Before and after diagram showing an MDX blog moving from runtime filesystem reads to a build-time registry for OpenNext on Cloudflare Workers

What Broke

The old setup looked something like this:

import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
 
const postsDir = path.join(process.cwd(), "content", "blog");
 
export function getAllPosts() {
  return fs.readdirSync(postsDir).map((file) => {
    const raw = fs.readFileSync(path.join(postsDir, file), "utf8");
    const { data } = matter(raw);
 
    return {
      slug: file.replace(/\.mdx$/, ""),
      title: data.title,
      description: data.description,
    };
  });
}

This feels fine in local development because the source files are right there on disk.

But after OpenNext builds the app for Cloudflare Workers, the app is running from a Worker bundle. Cloudflare does support node:fs through 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, and /tmp is temporary for a request.

So I stopped treating the source folder as runtime data.

The Fix

I moved blog discovery to build time.

The build step reads the MDX files once, parses the frontmatter, and writes generated TypeScript files that the app can import normally.

The mental model is simple:

Authoring:
  content/blog/*.mdx
 
Build:
  read MDX files
  parse frontmatter
  generate metadata
  generate static imports
 
Runtime:
  import generated modules
  render the matching MDX component

This keeps the nice file-based writing flow, but removes runtime filesystem reads from the deployed Worker.

Step 1: Generate Blog Metadata

I use a script before the build. It reads the .mdx files and creates metadata for the blog index, sitemap, RSS feed, and page metadata.

// scripts/generate-blog-data.mjs
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
 
const root = process.cwd();
const postsDir = path.join(root, "content", "blog");
const outputDir = path.join(root, "src", "lib", "blog");
 
const files = fs.readdirSync(postsDir).filter((file) => file.endsWith(".mdx"));
 
const posts = files.map((file) => {
  const slug = file.replace(/\.mdx$/, "");
  const raw = fs.readFileSync(path.join(postsDir, file), "utf8");
  const { data, content } = matter(raw);
 
  const words = content.trim().split(/\s+/).filter(Boolean).length;
 
  return {
    slug,
    title: data.title,
    description: data.description,
    date: data.date,
    readingTime: `${Math.max(1, Math.ceil(words / 225))} min read`,
  };
});
 
fs.mkdirSync(outputDir, { recursive: true });
 
fs.writeFileSync(
  path.join(outputDir, "generated-posts.ts"),
  `export const allBlogPosts = ${JSON.stringify(posts, null, 2)} as const;\n`
);

The important part is not the exact script. The important part is when it runs.

It runs before next build, not when someone opens /blog.

Step 2: Generate Static MDX Imports

The next file is the one that makes the Worker build reliable.

Instead of doing this:

await import(`../../../content/blog/${slug}.mdx`);

I generate static imports:

// src/lib/blog/generated-components.ts
import type { ComponentType } from "react";
 
import Post0 from "../../../content/blog/first-post.mdx";
import Post1 from "../../../content/blog/second-post.mdx";
 
const blogPostComponents: Record<string, ComponentType> = {
  "first-post": Post0,
  "second-post": Post1,
};
 
export function getPostComponent(slug: string) {
  return blogPostComponents[slug] ?? null;
}

This gives Next.js and OpenNext a clear import graph. They can see the MDX files, compile them, and include them in the Worker output.

That is much easier to trust than a variable import path.

Step 3: Render From the Registry

The blog route becomes a lookup, not a file scan.

import { notFound } from "next/navigation";
import { getPostComponent } from "@/lib/blog/generated-components";
import { allBlogPosts } from "@/lib/blog/generated-posts";
 
export function generateStaticParams() {
  return allBlogPosts.map((post) => ({ slug: post.slug }));
}
 
export default async function BlogPostPage({ params }) {
  const { slug } = await params;
  const post = allBlogPosts.find((item) => item.slug === slug);
  const PostContent = getPostComponent(slug);
 
  if (!post || !PostContent) {
    notFound();
  }
 
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.description}</p>
      <PostContent />
    </article>
  );
}

Now the production route only depends on bundled code.

No fs.readdirSync. No process.cwd(). No runtime gray-matter.

Step 4: Run the Script Before Every Build

I wired the generator into the build scripts:

{
  "scripts": {
    "prebuild": "node scripts/generate-blog-data.mjs",
    "build": "next build",
    "prebuild:cf": "node scripts/generate-blog-data.mjs",
    "build:cf": "opennextjs-cloudflare build -c wrangler.jsonc"
  }
}

Now when I add a new .mdx file, the next build updates the generated metadata and component registry.

I still get the same writing flow:

content/blog/my-new-post.mdx

But the deployed Worker gets predictable imports.

Step 5: Test the Worker Build

I do not stop at next build for this kind of bug.

next build can pass while the Worker output still behaves differently. So I check the Cloudflare build too:

pnpm build:cf

Then I preview the Worker locally:

pnpm exec opennextjs-cloudflare preview -c wrangler.jsonc

And I test three routes:

curl -I http://localhost:8787/blog
curl -I http://localhost:8787/blog/my-real-post
curl -I http://localhost:8787/blog/not-a-real-post

The result I want:

/blog                 200
/blog/my-real-post    200
/blog/not-a-real-post 404

The fake post matters. A working blog should render real posts and still reject bad slugs.

Quick Debug Checklist

  • Does any blog route import node:fs?
  • Does /blog call fs.readdirSync during a request?
  • Does the post page use a variable MDX import path?
  • Does the generated registry include every .mdx file?
  • Does pnpm build:cf list the expected blog routes?
  • Does the local Worker preview return 200 for a real post?
  • Does it return 404 for a fake post?

Where the Content Lives

The full post content still lives in MDX files.

The generated metadata file only stores things like:

  • slug
  • title
  • description
  • date
  • reading time
  • headings, if you need a table of contents

I do not put the whole article body into JSON. That gets messy fast, especially with code blocks, custom MDX components, and imports.

Instead, the body is compiled from the MDX module:

content/blog/my-post.mdx
        |
        v
import Post from "../../../content/blog/my-post.mdx"
        |
        v
<Post />

That is the clean split:

  • metadata is generated as plain data
  • content is rendered as compiled MDX

A Small SEO Note

The technical fix gets the pages to render. SEO still depends on whether the page is useful.

For this kind of technical post, I try to keep the basics simple:

  • use a clear title
  • describe the problem in the first few lines
  • use short sections
  • write headings that say what the section is about
  • add examples that someone can copy into their project
  • link to the official docs when they matter
  • avoid padding the post to hit a word count

That 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.

Final Thought

The fix was mostly a change in where the work happens.

Before, the Worker had to discover blog files at request time.

After, the build discovered the files once, generated a registry, and gave the Worker normal imports to render.

That made the blog simple again. I can still add posts as .mdx files, but production no longer depends on reading my source folder at runtime.

Further Reading