h6nk
← Back to writing

Why Static Blog Manifests Beat Filesystem Scanning

March 29, 2026·5 min read·nextjscloudflarearchitecture

Most Next.js blogs discover posts the same way: scan a directory at build time, parse each file's frontmatter, and return the list. It's clean, it's automatic, and it works everywhere — except Cloudflare Workers.

The Standard Pattern (and Where It Breaks)

The typical setup looks like this:

import fs from "fs";
import path from "path";
import matter from "gray-matter";
 
export function getAllPosts() {
  const dir = path.join(process.cwd(), "content/blog");
  const files = fs.readdirSync(dir); // 💥 on CF Workers at runtime
  return files.map((file) => {
    const source = fs.readFileSync(path.join(dir, file), "utf-8");
    const { data } = matter(source);
    return { slug: file.replace(".mdx", ""), ...data };
  });
}

This works during a Node.js build. It fails at runtime on Cloudflare Workers.

CF Workers does not expose a persistent filesystem to running code. The content directory is bundled as static assets — available at build time via Wrangler, not accessible via fs.readFileSync when a request comes in. You get a clear error:

Error: ENOENT: no such file or directory, scandir '/content/blog'

You can add next/dynamic workarounds, cache the result in a global, or restructure your imports. Or you can stop using the filesystem entirely.

The Static Manifest Pattern

This site uses a blog-manifest.ts file that is just an array of explicitly registered post objects:

import type { BlogPost } from "./blog";
 
export const blogManifest: BlogPost[] = [
  {
    slug: "static-blog-manifest-pattern",
    title: "Why Static Blog Manifests Beat Filesystem Scanning",
    description: "On Cloudflare Workers, fs.readdirSync fails at runtime...",
    date: "2026-03-20",
    tags: ["nextjs", "cloudflare", "architecture"],
    readingTime: 5,
    published: true,
  },
  // ... every post, explicitly listed
];

getAllPosts() imports this array, filters by published: true, sorts by date, and returns it. No filesystem. No glob. No build-time scanning magic.

The MDX files still exist in src/content/blog/ — Next.js imports them for the actual content when rendering a post page. But post discovery is entirely separate from content loading, and discovery never touches the filesystem.

Why This Is Actually Better

The CF Workers constraint forced a design that turns out to be better everywhere.

No accidental publishing. With directory scanning, any .mdx file in the content folder goes live. A draft you left around, an untitled experiment, a half-finished idea — all get published if you're not careful. With a manifest, a file only goes live when you add it. Nothing is published by accident.

TypeScript types at every post. The manifest is a typed array. Every post entry is BlogPost — the compiler catches missing fields, wrong types, and typos at build time, not at 2am when a visitor hits a broken route.

Staged publishing built in. The published: boolean field lets you add a post to the manifest (so you can test routing, OG images, and layout locally) without making it visible on the blog index. Set published: false, develop, flip the flag when it's ready.

Build-time validation. If a manifest entry references a slug that doesn't have a corresponding MDX file, next build fails. That's the right behavior. The manifest and the content directory are forced to stay in sync.

Faster runtime. There are no filesystem calls, no glob patterns, no frontmatter parsing at build time. getAllPosts() is a filtered sort over an in-memory array. It's as fast as it gets.

The Trade-off

You have to update the manifest when you add a post. This takes about five seconds.

// After writing the MDX file, add one object to blogManifest:
{
  slug: "my-new-post",
  title: "My New Post",
  description: "...",
  date: "2026-03-20",
  tags: ["example"],
  readingTime: 3,
  published: true,
}

If you forget, the post won't appear anywhere and the build will succeed. That's a small footgun. It's worth it.

The Bigger Pattern

The CF Workers constraint didn't just force a workaround — it forced a better architecture. Explicit registration is more robust than implicit discovery. Types are better than parsing. Compile-time errors are better than runtime errors.

This comes up more than you'd think. When a platform says "you can't do that here," it's often because the thing you were doing was already fragile. The constraint is doing you a favor.

The static manifest pattern works on Vercel, Netlify, Railway, and your own server just as well as it works on Cloudflare Workers. There's nothing CF-specific about it. The constraint just made it obvious.

If you're starting a new Next.js blog and you don't have this constraint, you might still reach for filesystem scanning — it's the default pattern, there's plenty of tooling for it, and it does work. But you'd be trading type safety and explicit control for a bit of automation. When the project grows and you have twenty posts, the manifest is still simple. When you want to stage a post or add per-post metadata fields, the manifest scales naturally. When you move hosting, there's nothing to break.

Write the file. Update the manifest. Ship the post. That's the whole loop, and it stays that simple at any scale.

← Back to writing