2025-07-21 by Remi Kristelijn
After successfully setting up my Next.js blog with MDX content and Material-UI, I encountered a critical deployment issue on Cloudflare. The application was failing to display blog posts, and the Cloudflare logs showed this error:
Error reading posts directory: Error: [unenv] fs.readdirSync is not implemented yet!
This error occurred because my blog was trying to use Node.js file system operations (fs.readdirSync) at runtime in the Cloudflare Workers environment, which doesn't support these Node.js APIs.
The issue stemmed from how I initially implemented the blog post fetching:
1// This was the problem - trying to read files at runtime 2export default function PostsPage() { 3 const posts = getAllPosts(); // This calls fs.readdirSync at runtime 4 // ... 5}
In a traditional Node.js environment, this works fine. However, Cloudflare Workers run in a V8 isolate environment that doesn't have access to Node.js file system APIs. The application was trying to read the src/content/posts/ directory at runtime, which simply isn't possible in this environment.
After several attempts with different approaches, I implemented a static data generation solution that moves all file system operations from runtime to build time. Here's the final working solution:
Create scripts/generate-posts-data.js:
1import fs from 'fs'; 2import path from 'path'; 3import matter from 'gray-matter'; 4 5const POSTS_DIRECTORY = path.join(process.cwd(), 'src/content/posts'); 6const OUTPUT_FILE = path.join(process.cwd(), 'src/data/posts.json'); 7 8// Ensure the data directory exists 9const dataDir = path.dirname(OUTPUT_FILE); 10if (!fs.existsSync(dataDir)) { 11 fs.mkdirSync(dataDir, { recursive: true }); 12} 13 14function generatePostsData() { 15 try { 16 console.log('Generating posts data...'); 17 18 // Read all MDX files 19 const fileNames = fs.readdirSync(POSTS_DIRECTORY); 20 const mdxFiles = fileNames.filter(fileName => fileName.endsWith('.mdx')); 21 22 const posts = mdxFiles.map(fileName => { 23 const slug = fileName.replace(/\.mdx$/, ''); 24 const fullPath = path.join(POSTS_DIRECTORY, fileName); 25 const fileContents = fs.readFileSync(fullPath, 'utf8'); 26 const { data, content } = matter(fileContents); 27 28 return { 29 id: slug, 30 slug, 31 title: data.title, 32 date: data.date, 33 excerpt: data.excerpt, 34 content 35 }; 36 }).filter(post => post.title && post.date && post.excerpt); 37 38 // Sort by date (newest first) 39 posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); 40 41 // Generate slugs array 42 const slugs = posts.map(post => post.slug); 43 44 // Create the data object 45 const postsData = { 46 posts, 47 slugs, 48 generatedAt: new Date().toISOString() 49 }; 50 51 // Write to JSON file 52 fs.writeFileSync(OUTPUT_FILE, JSON.stringify(postsData, null, 2)); 53 54 console.log(`✅ Generated posts data with ${posts.length} posts`); 55 console.log(`📁 Output: ${OUTPUT_FILE}`); 56 57 return postsData; 58 } catch (error) { 59 console.error('❌ Error generating posts data:', error); 60 process.exit(1); 61 } 62} 63 64// Run if called directly 65generatePostsData();
Create src/lib/posts-static.ts:
1/** 2 * Static posts data layer - reads from pre-generated JSON file 3 * 4 * This module reads posts from a static JSON file generated at build time, 5 * avoiding any runtime file system operations that aren't supported in 6 * Cloudflare Workers environment. 7 */ 8 9import type { Post } from '@/types'; 10 11// Import the static data (this will be bundled at build time) 12import postsData from '@/data/posts.json'; 13 14/** 15 * Get all blog posts from static data 16 * @returns Array of all posts with metadata 17 */ 18export function getAllPosts(): Post[] { 19 return postsData.posts; 20} 21 22/** 23 * Get a specific post by its slug 24 * @param slug - The post slug to look up 25 * @returns The post if found, undefined otherwise 26 */ 27export function getPostBySlug(slug: string): Post | undefined { 28 return postsData.posts.find((post: Post) => post.slug === slug); 29} 30 31/** 32 * Get all post slugs (useful for static generation) 33 * @returns Array of all post slugs 34 */ 35export function getAllPostSlugs(): string[] { 36 return postsData.slugs; 37} 38 39/** 40 * Check if a post exists 41 * @param slug - The post slug to check 42 * @returns True if the post exists, false otherwise 43 */ 44export function postExists(slug: string): boolean { 45 return postsData.slugs.includes(slug); 46}
Update src/app/posts/page.tsx:
1import { Container, Typography, Box } from '@mui/material'; 2import Navigation from '@/components/Navigation'; 3import PostCard from '@/components/PostCard'; 4import { getAllPosts } from '@/lib/posts-static'; 5import type { Metadata } from 'next'; 6 7/** 8 * Generate metadata for the posts page 9 */ 10export async function generateMetadata(): Promise<Metadata> { 11 const posts = getAllPosts(); 12 return { 13 title: 'Blog Posts', 14 description: `Browse all ${posts.length} blog posts`, 15 }; 16} 17 18/** 19 * Posts listing page - displays all available blog posts 20 * 21 * This page follows the C4C principle by using clear, reusable components 22 * and the HIPI principle by hiding implementation details behind clean interfaces. 23 * 24 * Uses static data to avoid runtime file system operations. 25 */ 26export default function PostsPage() { 27 // Get posts from static data (no file system operations) 28 const posts = getAllPosts(); 29 30 return ( 31 <Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}> 32 <Navigation title="Blog Posts" showHome={true} showBack={false} /> 33 34 <Container maxWidth="md" sx={{ flex: 1, py: 4 }}> 35 <Typography variant="h3" component="h1" gutterBottom sx={{ mb: 4 }}> 36 Blog Posts 37 </Typography> 38 39 <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> 40 {posts.map((post) => ( 41 <PostCard key={post.id} post={post} /> 42 ))} 43 </Box> 44 </Container> 45 </Box> 46 ); 47}
Update src/app/posts/[slug]/page.tsx:
1import { notFound } from 'next/navigation'; 2import { Container, Box } from '@mui/material'; 3import Navigation from '@/components/Navigation'; 4import PostContent from '@/components/PostContent'; 5import { getPostBySlug, getAllPostSlugs } from '@/lib/posts-static'; 6import type { PostPageProps } from '@/types'; 7import type { Metadata } from 'next'; 8 9/** 10 * Generate static params for all blog posts at build time 11 */ 12export async function generateStaticParams() { 13 try { 14 const slugs = getAllPostSlugs(); 15 console.log('Generated static params for slugs:', slugs); 16 return slugs.map((slug) => ({ 17 slug, 18 })); 19 } catch (error) { 20 console.error('Error generating static params:', error); 21 return []; 22 } 23} 24 25/** 26 * Generate metadata for individual post pages 27 */ 28export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> { 29 const { slug } = await params; 30 const post = getPostBySlug(slug); 31 32 if (!post) { 33 return { 34 title: 'Post Not Found', 35 }; 36 } 37 38 return { 39 title: post.title, 40 description: post.excerpt, 41 }; 42} 43 44/** 45 * Individual blog post page - displays a single blog post 46 * 47 * This page follows the C4C principle by using clear, reusable components 48 * and proper error handling. It also follows the HIPI principle by hiding 49 * data fetching logic behind clean interfaces. 50 * 51 * Uses static data to avoid runtime file system operations. 52 */ 53export default async function PostPage({ params }: PostPageProps) { 54 const { slug } = await params; 55 const post = getPostBySlug(slug); 56 57 if (!post) { 58 notFound(); 59 } 60 61 return ( 62 <Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}> 63 <Navigation title={post.title} showHome={true} showBack={true} /> 64 65 <Container maxWidth="md" sx={{ flex: 1, py: 4 }}> 66 <PostContent post={post} /> 67 </Container> 68 </Box> 69 ); 70}
Update package.json to include the build hook:
1{ 2 "name": "next-blog", 3 "version": "0.1.0", 4 "private": true, 5 "type": "module", 6 "scripts": { 7 "dev": "next dev --turbopack", 8 "prebuild": "node scripts/generate-posts-data.js", 9 "build": "next build", 10 "start": "next start", 11 "lint": "next lint", 12 "ci:build": "opennextjs-cloudflare build", 13 "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", 14 "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", 15 "cf-typegen": "wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts" 16 } 17}
node scripts/generate-posts-data.js runsfs.readdirSync ✅ Safe - runs on your machinesrc/data/posts.json with all post datafs.readdirSync calls on CloudflareThe script needed to be converted from CommonJS to ES modules:
1// Before (CommonJS) 2const fs = require('fs'); 3const path = require('path'); 4const matter = require('gray-matter'); 5 6// After (ES Modules) 7import fs from 'fs'; 8import path from 'path'; 9import matter from 'gray-matter';
And package.json needed:
1{ 2 "type": "module" 3}
The static posts library imports JSON directly:
1import postsData from '@/data/posts.json';
This works because:
The deployment now follows this optimized flow:
node scripts/generate-posts-data.js (GitHub Actions/Local)npm run ci:build (OpenNext build for Cloudflare)npm run deploy (Deploy to Cloudflare Workers)You can verify the fix worked by:
fs.readdirSync is not implemented yet! errorsDifferent deployment platforms have different capabilities. Cloudflare Workers is excellent for performance but has limitations compared to traditional Node.js servers.
For content-heavy sites like blogs, SSG provides the best performance, reliability, and cost-effectiveness.
Moving operations from runtime to build time often results in better performance and reliability.
When using ES modules, ensure all scripts and configurations are consistent.
Proper error handling in build-time operations prevents deployment failures and provides better debugging information.
The transition from runtime file system operations to static data generation was a crucial fix that transformed my blog from a broken deployment to a fast, reliable, and scalable application.
This approach aligns perfectly with the KISS principle (Keep It Simple, Stupid) and YAGNI principle (You Aren't Gonna Need It) from our development rules. We're using the simplest solution that works reliably, without over-engineering for features we don't need.
The blog is now live at https://next-blog.rkristelijn.workers.dev and serving all posts correctly with excellent performance.
Key Takeaway: When deploying to edge environments like Cloudflare Workers, always prefer static generation over runtime operations for content-heavy applications. The performance and reliability benefits are significant, and the implementation is often simpler than dynamic alternatives.
Final Note: The fs.readdirSync operations are now ONLY executed during build time on your machine or GitHub Actions, NEVER on Cloudflare Workers at runtime. This ensures complete compatibility with the Cloudflare Workers environment while maintaining all the functionality of a dynamic blog.