What to expect A detailed troubleshooting guide documenting a real-world issue: blog posts not displaying when deploying Next.js 15 with MDX to Cloudflare Workers using the OpenNext adapter. I'll walk you through the symptoms, root cause analysis, and the complete solution with code examples you can copy.
I had just built a beautiful blog using Next.js 15 with MDX, deployed it to Cloudflare Workers using the @opennextjs/cloudflare adapter, and... nothing. The blog index at /blog showed "No posts yet" even though I had three published posts in my content/blog/ directory.
Even after "fixing" the index to display post cards, clicking on any blog post URL like /blog/build-a-blog-with-nextjs-mdx returned a 404 error. The posts existed, they built successfully locally, but they were completely invisible in production on Cloudflare Workers.
This is the complete story of how I debugged and fixed both issues.
I had followed every best practice:
@next/mdxgenerateStaticParams and dynamicParams = falselocalhost:3000Then I deployed to Cloudflare Workers and hit a wall. The blog was completely non-functional. No posts on the index, 404s on individual pages.
The frustration was real: I could see the blog HTML files in .next/server/app/blog/ after building. The posts were being prerendered. So why weren't they accessible in production?
This guide is the investigation and solution I wish I'd found when searching for answers.
Think of Cloudflare Workers like a valet service at a restaurant:
Node.js on a server is like having a full kitchen. You can open drawers (filesystem), check the fridge (read files), and cook anything on demand.
Cloudflare Workers is like a food cart with a limited menu. You can't go "check the kitchen" for ingredients—you only have what's on the cart (static assets). The cart is fast and can be everywhere, but it's constrained.
When your Next.js app tries to:
The solution: Put everything the Workers runtime needs on the cart (static assets) and stop asking it to check the kitchen (no filesystem operations).
The blog index page at /blog displayed:
Blog
Notes on Next.js, React, MDX, and building accessible, fast products.
No posts yet.
Even though content/blog/ contained three .mdx files with published posts.
My blog index page (app/blog/page.tsx) was using a getAllPosts() function that relied on Node.js filesystem operations:
// lib/blog.ts (the problematic version)
import fs from 'fs';
import path from 'path';
export async function getAllPosts() {
const postsDirectory = path.join(process.cwd(), 'content/blog');
const filenames = fs.readdirSync(postsDirectory); // ❌ Doesn't work in Workers
const posts = filenames.map((filename) => {
const fullPath = path.join(postsDirectory, filename);
const fileContents = fs.readFileSync(fullPath, 'utf8'); // ❌ Doesn't work in Workers
// ... parse and return
});
return posts;
}
Why it failed:
Cloudflare Workers don't have access to Node.js's fs module, even with the nodejs_compat compatibility flag. The filesystem doesn't exist at runtime—only at build time.
When getAllPosts() tried to run in the Workers environment, it failed silently (or returned an empty array), causing "No posts yet" to display.
Instead of reading the filesystem at runtime, I created a static data file with all blog post metadata:
// lib/blog-posts-data.ts
export const BLOG_POSTS = [
{
slug: 'build-a-blog-with-nextjs-mdx',
meta: {
title: 'Why I Built My Blog on Next.js + MDX (and Exactly How)',
description: 'I wanted a fast, durable, file-based blog - no CMS. Next.js + first-class MDX fit perfectly. Here\'s the why, the exact setup, SEO, and workflow you can copy.',
date: '2025-09-16',
updated: '2025-09-16',
tags: ['nextjs','mdx','react','typescript'],
draft: false,
featured: true,
cover: '/images/blog/build-a-blog-with-nextjs-mdx/cover.svg',
coverAlt: 'Cover banner for the MDX + Next.js blog guide',
author: 'Rohit Ramachandran',
authorUrl: 'https://rohitai.com',
authorAvatar: '/images/rohit.jpg',
authorBio: 'AI developer building thoughtful, accessible products.'
}
},
{
slug: 'add-microsoft-sign-in-nextjs-azure-oidc',
meta: {
title: 'Add Microsoft Sign-In to Next.js with Azure Entra + OIDC (PKCE)',
description: 'ELI5, end-to-end guide to add Microsoft (Outlook + work/school) login to Next.js using Azure Entra OIDC with PKCE, secure cookies, Drizzle/Neon. Includes code, dev to prod, and troubleshooting.',
date: '2025-10-19',
tags: ['nextjs','typescript','javascript'],
draft: false,
cover: '/images/blog/add-microsoft-sign-in-nextjs/cover.svg',
coverAlt: 'Microsoft login flow diagram for Next.js with OAuth 2.0 and OIDC',
author: 'Rohit Ramachandran',
authorUrl: 'https://rohitai.com',
authorAvatar: '/images/rohit.jpg',
authorBio: 'AI developer building thoughtful, accessible products.'
}
},
{
slug: 'add-google-sign-in-nextjs-oauth-oidc',
meta: {
title: 'Add Google Sign-In to Next.js with OAuth 2.0 + OIDC (PKCE)',
description: 'Step-by-step, ELI5 guide to add Google login to Next.js using OAuth 2.0 + OIDC with PKCE, secure cookies, and Drizzle/Neon. Includes code, security rationale, dev to prod, and troubleshooting.',
date: '2025-10-19',
tags: ['nextjs','typescript','javascript','oauth'],
draft: false,
cover: '/images/blog/add-google-sign-in-nextjs/cover.svg',
coverAlt: 'Google login flow diagram for Next.js with OAuth 2.0 and OIDC',
author: 'Rohit Ramachandran',
authorUrl: 'https://rohitai.com',
authorAvatar: '/images/rohit.jpg',
authorBio: 'AI developer building thoughtful, accessible products.'
}
}
];
Then I updated the blog index page to use this static data:
// app/blog/page.tsx (fixed version)
import { BLOG_POSTS } from '@/lib/blog-posts-data';
export default async function BlogPage() {
const isProd = process.env.NODE_ENV === 'production';
// Filter out drafts in production
const posts = BLOG_POSTS.filter((p) => (isProd ? !p.meta.draft : true));
// Sort by date descending
const sorted = posts.sort((a, b) => {
return new Date(b.meta.date).getTime() - new Date(a.meta.date).getTime();
});
if (sorted.length === 0) {
return <p>No posts yet.</p>;
}
return (
<div>
{sorted.map((post) => (
<PostCard key={post.slug} post={post} />
))}
</div>
);
}
Result: The blog index now works perfectly. No filesystem operations, no runtime dependencies—just static data that works everywhere.
After fixing the blog index, I could see all three blog posts listed. But clicking on any post link resulted in a 404 error:
https://rohitai.com/blog/build-a-blog-with-nextjs-mdx → 404https://rohitai.com/blog/add-microsoft-sign-in-nextjs-azure-oidc → 404https://rohitai.com/blog/add-google-sign-in-nextjs-oauth-oidc → 404I verified that my blog post page was configured correctly for static generation:
// app/blog/[slug]/page.tsx
export const dynamicParams = false; // ✅ Correct: only pre-generate, no runtime fallback
export async function generateStaticParams() {
const prod = process.env.NODE_ENV === 'production';
const posts = await getAllPosts({ includeDrafts: !prod, includeFuture: true });
return posts
.filter((p) => (prod ? !p.meta.draft : true))
.map((p) => ({ slug: p.slug }));
}
export default async function BlogPostPage({
params
}: {
params: { slug: string }
}) {
const { slug } = params;
// Dynamically import the MDX file
const Post = await import(`@/content/blog/${slug}.mdx`);
return (
<article>
<Post.default />
</article>
);
}
This setup is correct for SSG (Static Site Generation). With dynamicParams = false, Next.js should prerender all blog post pages at build time.
I checked the build output and found the prerendered HTML files:
$ ls .next/server/app/blog/
build-a-blog-with-nextjs-mdx.html
add-microsoft-sign-in-nextjs-azure-oidc.html
add-google-sign-in-nextjs-oauth-oidc.html
page.html
search.html
tags.html
✅ The pages WERE being prerendered! Each blog post had a corresponding .html file.
The issue was with the OpenNext Cloudflare adapter. I checked what was actually being deployed:
$ ls .open-next/assets/
_next/
favicon.ico
images/
robots.txt
sitemap.xml
$ ls .open-next/assets/__cache/
fetch-cache/
...
The prerendered blog HTML files were missing!
The OpenNext build process (npx @opennextjs/cloudflare build) was:
.open-next/ output directorypublic/.next/server/app/blog/*.htmlWhen Cloudflare Workers tried to serve /blog/build-a-blog-with-nextjs-mdx, it looked for the corresponding HTML in the assets directory, didn't find it, and returned 404.
The @opennextjs/cloudflare adapter focuses on:
For fully static pages, it expects you to explicitly configure which assets should be served. The default behavior doesn't automatically copy all prerendered HTML from .next/server/app/.
This is documented (though not prominently) in OpenNext's architecture: static HTML pages must be explicitly placed in the assets directory or served via the configured cache mechanism.
I needed to copy the prerendered blog HTML files to the assets directory as part of the build process.
Step 1: Manual test to verify the fix
# Copy blog HTML files to assets
mkdir -p .open-next/assets/blog
cp -r .next/server/app/blog/*.html .open-next/assets/blog/
# Deploy
wrangler deploy
I deployed and tested: All blog posts worked! ✅
Step 2: Automate in the build script
I updated package.json to automate this process:
{
"scripts": {
"build:workers": "npx @opennextjs/cloudflare build && mkdir -p .open-next/assets/blog && cp -r .next/server/app/blog/*.html .open-next/assets/blog/ && mkdir -p .open-next/assets/__cache && cp -r .open-next/cache/* .open-next/assets/__cache/"
}
}
What this does:
npx @opennextjs/cloudflare build - Runs the standard OpenNext buildmkdir -p .open-next/assets/blog - Creates the blog directory in assetscp -r .next/server/app/blog/*.html .open-next/assets/blog/ - Copies all prerendered blog HTMLmkdir -p .open-next/assets/__cache - Creates cache directorycp -r .open-next/cache/* .open-next/assets/__cache/ - Copies cache filesNow every build automatically includes the prerendered blog pages in the assets directory where Cloudflare Workers can serve them.
For reference, here's my full OpenNext configuration that works with this setup:
// 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,
enableCacheInterception: true,
});
# wrangler.toml
name = "rohitai"
compatibility_date = "2025-04-01"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = ".open-next/assets"
binding = "ASSETS"
[[routes]]
pattern = "rohitai.com/*"
custom_domain = true
[[routes]]
pattern = "www.rohitai.com/*"
custom_domain = true
The key settings:
staticAssetsIncrementalCache - Tells OpenNext to use static assets for cachingenableCacheInterception - Enables cache-first serving[assets] in wrangler.toml - Points to .open-next/assets where our HTML livesHere's the full process from code to production:
# 1. Build Next.js with prerendering
npm run build
# 2. Build for Cloudflare Workers (includes HTML copying)
npm run build:workers
# 3. Deploy to Cloudflare
wrangler deploy
Or use the combined command:
npm run deploy
Which runs both build steps and deployment:
{
"scripts": {
"deploy": "npm run build:workers && wrangler deploy"
}
}
After deploying, verify everything works:
Blog Index:
/blogIndividual Posts:
Build Output:
.next/server/app/blog/*.html files exist after npm run build.open-next/assets/blog/*.html files exist after npm run build:workers.open-next/assets/__cache/Performance:
Here's how the pieces fit together:
content/blog/*.mdx filesgenerateStaticParams() tells Next.js which slugs to prerender.next/server/app/blog/[slug].html for each post.open-next/assets/blog//blog/build-a-blog-with-nextjs-mdxblog/build-a-blog-with-nextjs-mdx.htmlBLOG_POSTS array (no fs required)Check:
BLOG_POSTS imported correctly in app/blog/page.tsx?draft: false?Check:
npm run build and verify .next/server/app/blog/*.html existsnpm run build:workers and verify .open-next/assets/blog/*.html existsdynamicParams = false is set in app/blog/[slug]/page.tsxwrangler deploy (not just npm run build:workers)Check:
.next/server/app/blog/ exists (run npm run build first)*.html should match your filesmkdir -p to avoid errors if directory existsThis is the classic issue!
Local development uses Node.js server which CAN read filesystem. Production Cloudflare Workers CANNOT.
Solution:
BLOG_POSTS for metadata (no fs operations)fs.readFileSync or similar in runtime codeCheck:
generateStaticParams()?Check:
/public/images/blog/[slug]/ directory/images/blog/[slug]/image.pngpublic/ directory before buildpublic/ to .open-next/assets/ automaticallyIdea: Import MDX files on-demand when a post is requested.
Why it doesn't work:
Workers can't do dynamic filesystem imports. The import() statement works at build time but not runtime in the Workers environment.
Idea: Store blog metadata in Cloudflare KV and read at runtime.
Why I didn't use it: Adds complexity and external dependencies. The hardcoded approach is simpler, faster (no KV fetch), and easier to maintain. KV makes sense for frequently updated data, but blog posts are static content.
Idea: Create a JSON file with all post metadata at build time, import it at runtime.
Why I didn't use it:
This is essentially what BLOG_POSTS does, but less type-safe. Exporting from a .ts file gives TypeScript validation.
Idea: Use Next.js middleware to rewrite blog routes to static files.
Why it doesn't work: The files still need to be in the assets directory. Middleware doesn't help if the HTML isn't where Workers can access it.
✅ Good fit if you:
generateStaticParams❌ Not ideal if you:
Q: Why can't Cloudflare Workers access the filesystem? A: Workers run in V8 isolates, not traditional servers. They're lightweight and stateless by design. There's no filesystem to access—only assets you explicitly include.
Q: Does this mean I have to update BLOG_POSTS manually every time I add a post?
A: Yes, but it's minimal effort. You add the MDX file AND one entry to BLOG_POSTS. You could potentially generate this at build time, but manual is simpler and more explicit.
Q: What about generateStaticParams? Is it useless now?
A: No! generateStaticParams still tells Next.js which pages to prerender. Without it, the HTML files wouldn't be created. The hardcoded BLOG_POSTS is just for the index page listing.
Q: Can I use this with Incremental Static Regeneration (ISR)?
A: Partially. ISR requires cache invalidation and regeneration logic. The staticAssetsIncrementalCache supports some ISR patterns, but full ISR is tricky on Workers. For most blogs, full SSG is simpler and faster.
Q: Will this work with App Router's automatic static optimization? A: Yes! The pages are fully static. Next.js optimizes them, and we just ensure Workers can serve the output.
Q: What happens if I forget to copy the HTML files?
A: Individual blog posts will return 404. The build script ensures this happens automatically now, but if you manually deploy .open-next/ without running the full build:workers script, you'll see 404s.
Q: Can I use this approach for other dynamic routes?
A: Absolutely! Any route using generateStaticParams with dynamicParams = false can be served this way. Just copy the prerendered HTML to the assets directory.
Q: Does this affect SEO? A: No, this is better for SEO! Fully static HTML served from CDN edge locations is the fastest option. Search engines get clean HTML instantly.
Q: What about client-side navigation? A: Next.js handles this. Client-side routing still works—it fetches the static HTML via the Router instead of full page reloads.
After implementing this solution, here's what improved:
Before (when it worked locally but not in prod):
After:
This is as fast as a blog can possibly be—static HTML on a global CDN with no server-side processing.
This approach is inherently secure because:
The only attack surface is the build process itself, which is in your control (CI/CD).
When adding a new blog post:
content/blog/new-post-slug.mdxlib/blog-posts-data.ts/public/images/blog/new-post-slug/npm run build to test locallynpm run deploy to publishWhen updating an existing post:
updated field in metadatalib/blog-posts-data.ts if changedQuarterly checklist:
If you have an existing Next.js blog on Vercel, Netlify, or traditional hosting:
Step 1: Verify your blog works locally
npm run build
npm run start
# Visit http://localhost:3000/blog
Step 2: Install Cloudflare dependencies
npm install -D @opennextjs/cloudflare wrangler
Step 3: Create hardcoded blog metadata
Extract metadata from your current getAllPosts() function and create lib/blog-posts-data.ts.
Step 4: Update blog index to use static data
Replace filesystem calls with BLOG_POSTS import.
Step 5: Configure OpenNext
Create open-next.config.ts (see example above).
Step 6: Update build script
Add the HTML copying logic to your build:workers script.
Step 7: Test locally with Wrangler
npm run build:workers
npx wrangler dev
Step 8: Deploy to Cloudflare
npx wrangler deploy
Step 9: Update DNS Point your domain to Cloudflare Workers.
Step 10: Verify Test all blog posts in production.
For reference, here's the relevant project structure:
frontend/
├── app/
│ └── blog/
│ ├── page.tsx # Blog index (uses BLOG_POSTS)
│ └── [slug]/
│ ├── page.tsx # Post template (SSG)
│ └── opengraph-image.tsx # OG images
├── content/
│ └── blog/
│ ├── build-a-blog-with-nextjs-mdx.mdx
│ ├── add-google-sign-in-nextjs-oauth-oidc.mdx
│ └── add-microsoft-sign-in-nextjs-azure-oidc.mdx
├── lib/
│ ├── blog.ts # Helper functions (optional)
│ └── blog-posts-data.ts # Hardcoded metadata ⭐
├── public/
│ └── images/
│ └── blog/
│ └── [slug]/ # Images per post
├── .next/
│ └── server/
│ └── app/
│ └── blog/
│ └── *.html # Prerendered (build output)
├── .open-next/
│ └── assets/
│ ├── blog/
│ │ └── *.html # Copied for Workers ⭐
│ └── images/ # From public/
├── open-next.config.ts # OpenNext configuration
├── wrangler.toml # Cloudflare Workers config
└── package.json # Build scripts
This was a frustrating bug to diagnose because:
The lesson: Cloudflare Workers is not a Node.js server. It's a different runtime with different constraints. Understanding these constraints is key to building applications that work correctly in production.
The solution—hardcoded metadata and explicit HTML asset copying—feels a bit manual, but it's:
If you're deploying Next.js to Cloudflare Workers, save yourself hours of debugging: understand what can and can't run at the edge, prerender everything possible, and make your assets explicit.
Have questions or run into issues? Feel free to reach out. This solution worked for my setup (Next.js 15.5.2, @opennextjs/cloudflare 1.11.1, Cloudflare Workers), and I hope it saves you the debugging time I spent figuring it out.