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:
// This was the problem - trying to read files at runtime export default function PostsPage() { const posts = getAllPosts(); // This calls fs.readdirSync at runtime // ... }
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:
import fs from 'fs'; import path from 'path'; import matter from 'gray-matter'; const POSTS_DIRECTORY = path.join(process.cwd(), 'src/content/posts'); const OUTPUT_FILE = path.join(process.cwd(), 'src/data/posts.json'); // Ensure the data directory exists const dataDir = path.dirname(OUTPUT_FILE); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } function generatePostsData() { try { console.log('Generating posts data...'); // Read all MDX files const fileNames = fs.readdirSync(POSTS_DIRECTORY); const mdxFiles = fileNames.filter(fileName => fileName.endsWith('.mdx')); const posts = mdxFiles.map(fileName => { const slug = fileName.replace(/\.mdx$/, ''); const fullPath = path.join(POSTS_DIRECTORY, fileName); const fileContents = fs.readFileSync(fullPath, 'utf8'); const { data, content } = matter(fileContents); return { id: slug, slug, title: data.title, date: data.date, excerpt: data.excerpt, content }; }).filter(post => post.title && post.date && post.excerpt); // Sort by date (newest first) posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); // Generate slugs array const slugs = posts.map(post => post.slug); // Create the data object const postsData = { posts, slugs, generatedAt: new Date().toISOString() }; // Write to JSON file fs.writeFileSync(OUTPUT_FILE, JSON.stringify(postsData, null, 2)); console.log(`✅ Generated posts data with ${posts.length} posts`); console.log(`📁 Output: ${OUTPUT_FILE}`); return postsData; } catch (error) { console.error('❌ Error generating posts data:', error); process.exit(1); } } // Run if called directly generatePostsData();
Create src/lib/posts-static.ts:
/** * Static posts data layer - reads from pre-generated JSON file * * This module reads posts from a static JSON file generated at build time, * avoiding any runtime file system operations that aren't supported in * Cloudflare Workers environment. */ import type { Post } from '@/types'; // Import the static data (this will be bundled at build time) import postsData from '@/data/posts.json'; /** * Get all blog posts from static data * @returns Array of all posts with metadata */ export function getAllPosts(): Post[] { return postsData.posts; } /** * Get a specific post by its slug * @param slug - The post slug to look up * @returns The post if found, undefined otherwise */ export function getPostBySlug(slug: string): Post | undefined { return postsData.posts.find((post: Post) => post.slug === slug); } /** * Get all post slugs (useful for static generation) * @returns Array of all post slugs */ export function getAllPostSlugs(): string[] { return postsData.slugs; } /** * Check if a post exists * @param slug - The post slug to check * @returns True if the post exists, false otherwise */ export function postExists(slug: string): boolean { return postsData.slugs.includes(slug); }
Update src/app/posts/page.tsx:
import { Container, Typography, Box } from '@mui/material'; import Navigation from '@/components/Navigation'; import PostCard from '@/components/PostCard'; import { getAllPosts } from '@/lib/posts-static'; import type { Metadata } from 'next'; /** * Generate metadata for the posts page */ export async function generateMetadata(): Promise<Metadata> { const posts = getAllPosts(); return { title: 'Blog Posts', description: `Browse all ${posts.length} blog posts`, }; } /** * Posts listing page - displays all available blog posts * * This page follows the C4C principle by using clear, reusable components * and the HIPI principle by hiding implementation details behind clean interfaces. * * Uses static data to avoid runtime file system operations. */ export default function PostsPage() { // Get posts from static data (no file system operations) const posts = getAllPosts(); return ( <Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}> <Navigation title="Blog Posts" showHome={true} showBack={false} /> <Container maxWidth="md" sx={{ flex: 1, py: 4 }}> <Typography variant="h3" component="h1" gutterBottom sx={{ mb: 4 }}> Blog Posts </Typography> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> {posts.map((post) => ( <PostCard key={post.id} post={post} /> ))} </Box> </Container> </Box> ); }
Update src/app/posts/[slug]/page.tsx:
import { notFound } from 'next/navigation'; import { Container, Box } from '@mui/material'; import Navigation from '@/components/Navigation'; import PostContent from '@/components/PostContent'; import { getPostBySlug, getAllPostSlugs } from '@/lib/posts-static'; import type { PostPageProps } from '@/types'; import type { Metadata } from 'next'; /** * Generate static params for all blog posts at build time */ export async function generateStaticParams() { try { const slugs = getAllPostSlugs(); console.log('Generated static params for slugs:', slugs); return slugs.map((slug) => ({ slug, })); } catch (error) { console.error('Error generating static params:', error); return []; } } /** * Generate metadata for individual post pages */ export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> { const { slug } = await params; const post = getPostBySlug(slug); if (!post) { return { title: 'Post Not Found', }; } return { title: post.title, description: post.excerpt, }; } /** * Individual blog post page - displays a single blog post * * This page follows the C4C principle by using clear, reusable components * and proper error handling. It also follows the HIPI principle by hiding * data fetching logic behind clean interfaces. * * Uses static data to avoid runtime file system operations. */ export default async function PostPage({ params }: PostPageProps) { const { slug } = await params; const post = getPostBySlug(slug); if (!post) { notFound(); } return ( <Box sx={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}> <Navigation title={post.title} showHome={true} showBack={true} /> <Container maxWidth="md" sx={{ flex: 1, py: 4 }}> <PostContent post={post} /> </Container> </Box> ); }
Update package.json to include the build hook:
{ "name": "next-blog", "version": "0.1.0", "private": true, "type": "module", "scripts": { "dev": "next dev --turbopack", "prebuild": "node scripts/generate-posts-data.js", "build": "next build", "start": "next start", "lint": "next lint", "ci:build": "opennextjs-cloudflare build", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", "preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "cf-typegen": "wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts" } }
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:
// Before (CommonJS) const fs = require('fs'); const path = require('path'); const matter = require('gray-matter'); // After (ES Modules) import fs from 'fs'; import path from 'path'; import matter from 'gray-matter';
And package.json needed:
{ "type": "module" }
The static posts library imports JSON directly:
import 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.