Fix Next.js Blog Not Displaying on Cloudflare Workers: SSG + MDX Guide
Fix Next.js Blog Not Displaying on Cloudflare Workers: The Complete SSG + MDX Solution
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.
The Problem: Blog Posts Not Showing Up
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.
The Story: When Your Perfect Local Setup Breaks in Production
I had followed every best practice:
- Next.js 15.5.2 with App Router
- MDX for blog content with
@next/mdx - Static generation with
generateStaticParamsanddynamicParams = false - The OpenNext Cloudflare adapter configured correctly
- Everything worked perfectly on
localhost:3000
Then 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.
Understanding the Problem (ELI5)
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:
- Read blog files at runtime → It's asking the food cart to "check the fridge." Not possible.
- Serve prerendered pages → It's expecting dishes to be on the cart. If they're not loaded onto the cart (assets directory), they can't be served.
The solution: Put everything the Workers runtime needs on the cart (static assets) and stop asking it to check the kitchen (no filesystem operations).
Issue #1: Blog Index Showing "No Posts Yet"
The Symptom
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.
Root Cause
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.
The Solution: Hardcoded Blog Metadata
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.
Issue #2: Individual Blog Posts Returning 404
The Symptom
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→ 404
Initial Investigation
I 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.
Build Output Verification
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.
Root Cause Discovery
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:
- ✅ Correctly building the Next.js app with prerendered pages
- ✅ Creating the
.open-next/output directory - ✅ Copying static assets from
public/ - ❌ NOT copying the prerendered HTML files from
.next/server/app/blog/*.html
When 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.
Why OpenNext Doesn't Copy Prerendered HTML Automatically
The @opennextjs/cloudflare adapter focuses on:
- Server function bundling
- API routes
- Caching infrastructure
- ISR (Incremental Static Regeneration)
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.
The Solution: Manual HTML Asset Copying
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 files
Now every build automatically includes the prerendered blog pages in the assets directory where Cloudflare Workers can serve them.
Complete OpenNext Configuration
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/assetswhere our HTML lives
The Complete Deployment Workflow
Here'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"
}
}
Verification Checklist
After deploying, verify everything works:
Blog Index:
- [ ] Navigate to
/blog - [ ] See all published posts (not drafts)
- [ ] Post cards show title, description, date, tags
- [ ] Cover images load correctly
Individual Posts:
- [ ] Click on each post card
- [ ] Post content renders fully
- [ ] Images within posts load
- [ ] Code blocks are styled correctly
- [ ] No 404 errors
Build Output:
- [ ]
.next/server/app/blog/*.htmlfiles exist afternpm run build - [ ]
.open-next/assets/blog/*.htmlfiles exist afternpm run build:workers - [ ] Cache files in
.open-next/assets/__cache/
Performance:
- [ ] Blog index loads quickly (static HTML)
- [ ] Individual posts load quickly (prerendered HTML)
- [ ] No runtime filesystem operations
- [ ] Check Cloudflare Analytics for response times
Understanding the Architecture
Here's how the pieces fit together:
Build Time (Local)
- Next.js reads
content/blog/*.mdxfiles generateStaticParams()tells Next.js which slugs to prerender- Next.js generates
.next/server/app/blog/[slug].htmlfor each post - OpenNext adapter processes the build
- Custom build script copies HTML to
.open-next/assets/blog/
Runtime (Cloudflare Workers)
- Request comes in for
/blog/build-a-blog-with-nextjs-mdx - Workers looks in assets directory for
blog/build-a-blog-with-nextjs-mdx.html - Finds the prerendered HTML (because we copied it)
- Serves it directly with cache headers
- No filesystem operations, no dynamic rendering
Why This Works
- Blog index: Uses hardcoded
BLOG_POSTSarray (no fs required) - Individual posts: Served as prerendered static HTML from assets
- No runtime dependencies: Everything needed is in the assets directory
- Fast: Static HTML served from CDN edge locations
Troubleshooting Common Issues
Blog index still shows "No posts yet"
Check:
- Is
BLOG_POSTSimported correctly inapp/blog/page.tsx? - Are posts marked as
draft: false? - Is the date not in the future (if filtering)?
- Clear browser cache and redeploy
Individual posts return 404
Check:
- Run
npm run buildand verify.next/server/app/blog/*.htmlexists - Run
npm run build:workersand verify.open-next/assets/blog/*.htmlexists - Check that the slug in the URL matches the HTML filename exactly
- Verify
dynamicParams = falseis set inapp/blog/[slug]/page.tsx - Deploy with
wrangler deploy(not justnpm run build:workers)
Build script fails with "no such file or directory"
Check:
- Make sure
.next/server/app/blog/exists (runnpm run buildfirst) - The glob pattern
*.htmlshould match your files - Use
mkdir -pto avoid errors if directory exists - Check file permissions
Posts work locally but not in production
This is the classic issue!
Local development uses Node.js server which CAN read filesystem. Production Cloudflare Workers CANNOT.
Solution:
- Use hardcoded
BLOG_POSTSfor metadata (no fs operations) - Copy prerendered HTML to assets (no runtime generation)
- Never call
fs.readFileSyncor similar in runtime code
Some posts work, others don't
Check:
- Are the missing posts in
generateStaticParams()? - Do the missing posts have valid MDX that compiles?
- Check build logs for errors during prerendering
- Verify all HTML files were copied to assets directory
Images in blog posts don't load
Check:
- Images are in
/public/images/blog/[slug]/directory - Image paths in MDX use absolute paths:
/images/blog/[slug]/image.png - Images were included in the
public/directory before build - OpenNext copies
public/to.open-next/assets/automatically
Alternative Approaches Considered
1. Dynamic Imports at Runtime
Idea: 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.
2. Edge Config or KV Store
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.
3. Generating a JSON Manifest
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.
4. Using Middleware to Rewrite Requests
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.
When to Use This Approach
✅ Good fit if you:
- Have a Next.js 15 blog with MDX content
- Want to deploy to Cloudflare Workers for global edge performance
- Use static generation (SSG) with
generateStaticParams - Have a manageable number of blog posts (< 1000)
- Prefer Git-based content over a CMS
❌ Not ideal if you:
- Need fully dynamic blog content (use Edge Config or KV instead)
- Have thousands of blog posts (consider pagination and partial prerendering)
- Want authors to add posts without rebuilding (use a headless CMS)
- Need ISR (Incremental Static Regeneration) - requires different setup
FAQ
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.
Performance Benefits
After implementing this solution, here's what improved:
Before (when it worked locally but not in prod):
- Local: Fast (SSG)
- Production: 404 errors
After:
- Blog index: Instant (prerendered HTML, hardcoded data, no DB/fs calls)
- Individual posts: Instant (prerendered HTML served from Cloudflare edge)
- Global distribution: Posts served from 300+ Cloudflare data centers worldwide
- Time to First Byte (TTFB): < 50ms globally
- No cold starts: Static assets don't need Workers initialization
- Caching: Automatic edge caching with long TTLs
This is as fast as a blog can possibly be—static HTML on a global CDN with no server-side processing.
Security Considerations
This approach is inherently secure because:
- No dynamic code execution: Everything is prerendered at build time
- No filesystem access: Workers can't read unexpected files
- No database queries: The index uses static data
- No injection risks: MDX is compiled at build time, not runtime
- Content Security Policy: Easy to implement strict CSP with static content
The only attack surface is the build process itself, which is in your control (CI/CD).
Maintenance Tips
When adding a new blog post:
- Create
content/blog/new-post-slug.mdx - Add metadata to the MDX file
- Add entry to
lib/blog-posts-data.ts - Add images to
/public/images/blog/new-post-slug/ - Run
npm run buildto test locally - Run
npm run deployto publish
When updating an existing post:
- Edit the MDX file
- Update the
updatedfield in metadata - Update the metadata in
lib/blog-posts-data.tsif changed - Rebuild and redeploy
Quarterly checklist:
- [ ] Verify all posts still render correctly
- [ ] Check that new Next.js/OpenNext versions don't break the build
- [ ] Review Cloudflare Analytics for performance
- [ ] Test on multiple devices and browsers
- [ ] Validate HTML and accessibility
Migration Path for Existing Blogs
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.
Code Repository Structure
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
Final Thoughts
This was a frustrating bug to diagnose because:
- Everything worked perfectly locally
- The build appeared successful
- The error (404) didn't point to the root cause
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:
- ✅ Simple and explicit
- ✅ Fast (no runtime overhead)
- ✅ Reliable (no edge cases with filesystem operations)
- ✅ Maintainable (clear what's happening)
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.
Further Reading
- Next.js Static Exports
- OpenNext Cloudflare Adapter Docs
- Cloudflare Workers Runtime APIs
- Cloudflare Workers Static Assets
- MDX Documentation
- Next.js generateStaticParams
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.