How This Blog Is Built

Published 2026-05-06

This blog is pretty minimal, but its architecture was driven by one major constraint: it's deployed to Cloudflare Workers. Since Cloudflare Workers don't have access to the filesystem (node:fs) at runtime, we had to get a little creative with how we discover and load content.

1. Everything is MDX

All the posts live in the src/posts/ directory as .mdx files. MDX is great because it lets you write standard Markdown but drop in React components whenever you need something interactive.

Normally, you might use fs to read these files at runtime, but we can't do that here. Instead, we have a script (scripts/build-posts.mjs) that runs during the build step. It finds all the MDX files and generates a posts-loaders.ts file containing a literal import() for each post:

// AUTO-GENERATED by scripts/build-posts.mjs — do not edit by hand.
import type { LoaderMap } from "@/lib/post-content";

export const postLoaders: Record<string, LoaderMap> = {
	"blog-architecture": {
		en: () => import("@/posts/blog-architecture.en.mdx"),
		zh: () => import("@/posts/blog-architecture.zh.mdx"),
	},
};

Writing out exact import() statements like this is actually a win-win. It lets our bundler easily split each post into its own chunk. When a reader opens a post, the loadPostMDX function just checks this map and loads the right file.

And because it's MDX, we can freely import UI components right into the flow of the post. For example:

Every post also includes an export const metadata block, which gives us a single source of truth for the title, date, description, and tags.

2. Bilingual out of the box

I wanted the blog to support both English and Chinese. The easiest way to handle this was directly in the filenames, like <slug>.<locale>.mdx.

If someone visits a route for a locale that doesn't exist yet (say, a Chinese URL for an English-only post), the loader gracefully falls back to the English version:

const exact = loaders[locale];
if (exact) return { module: await exact(), locale, usedFallback: false };
const fallback = loaders[DEFAULT_LOCALE];
return { module: await fallback(), locale: DEFAULT_LOCALE, usedFallback: true };

Things like the title and description are localized, while the date and tags are shared. If the English and Chinese files have mismatching dates, the build script will complain and default to the English one so it's easy to fix.

3. Doing the heavy lifting at build time

Since we can't scan directories on the server, we just do it beforehand. We wrote a couple of short Node scripts that generate plain TypeScript modules that the app can import:

ScriptOutputWhat it does
build-posts.mjssrc/lib/posts-discovered.tsExtracts metadata from every MDX file
build-posts.mjssrc/lib/posts-loaders.tsWrites out the import() maps
build-gallery.mjssrc/lib/gallery-discovered.tsFinds all the <img> tags meant for the gallery

These run automatically right before next dev, next build, and deployments:

"posts:build":    "node scripts/build-posts.mjs",
"gallery:build":  "node scripts/build-gallery.mjs",
"manifest:build": "pnpm posts:build && pnpm gallery:build",
"dev":   "pnpm manifest:build && next dev",
"build": "pnpm manifest:build && next build"

4. How the post list works

The build-posts.mjs script doesn't just look at file names; it actually extracts the export const metadata = { … } block out of each MDX file. It then organizes everything by slug and bundles the locales together into a clean object:

export type PostMeta = {
	slug: string;
	title: LocalizedString;
	description: LocalizedString;
	date: string;
	tags: string[];
	locales: Locale[];
};

This makes building the post list and the search feature incredibly easy. The search only checks the metadata fields (title, description, tags). We decided to skip full-text search for now because that would mean bundling the entire content of every post into the app, which is overkill.

5. The CMS-free gallery

I didn't want to build a separate CMS just to manage the photo gallery. Instead, the build-gallery.mjs script searches through all the MDX posts for any <img> tags that have a data-gallery attribute:

If an image shows up in both the English and Chinese versions of a post, the script is smart enough to merge the localized captions and alt text together. The image only appears once in the gallery, but it knows about both translations.

If you drop an image in a post without the data-gallery attribute, it just renders normally in the article and stays out of the global gallery.

6. Routing with Next.js

The routing is built on the Next.js App Router under src/app/[locale]/. For the dynamic post routes, Next.js generates static pages for every locale and slug combination:

export function generateStaticParams() {
	return SUPPORTED_LOCALES.flatMap((locale) =>
		getAllPosts().map((post) => ({ locale, slug: post.slug })),
	);
}

export async function generateMetadata({ params }) {
	const { locale, slug } = await params;
	const loaded = await loadPostMDX(slug, locale);
	return {
		title: loaded.module.metadata?.title,
		description: loaded.module.metadata?.description,
	};
}

Since the metadata comes straight from the actual MDX files, the page title and the article listing never get out of sync.

7. Making it look good with Tailwind

By default, Tailwind CSS strips all styling from standard HTML tags like <h1> or <p>. While that's great for building application UIs, it makes writing long-form content tedious.

To fix this, we use the @tailwindcss/typography plugin. You just slap a prose class on a wrapper element, and it automatically handles line-heights, spacing, blockquotes, and even dark mode (prose-invert):

<article
	className="prose prose-neutral max-w-none dark:prose-invert
		prose-headings:font-semibold
		prose-a:text-blue-600 dark:prose-a:text-blue-400
		prose-pre:bg-neutral-900 prose-pre:text-neutral-100"
>
	{children}
</article>

"Good typography is invisible. Bad typography is everywhere."

If you need a custom component to ignore these typographic defaults, you can just wrap it in not-prose, which is exactly how the interactive buttons in the first section are styled.

8. Wrapping it up

Here's the entire workflow in a nutshell:

  1. Write <slug>.en.mdx and <slug>.zh.mdx inside src/posts/. Add your metadata export and any gallery images using <img data-gallery />.
  2. Run pnpm manifest:build to generate the TypeScript manifests.
  3. Next.js builds the static pages using those manifests.
  4. opennextjs-cloudflare packages everything up for Cloudflare Workers.

It's a pretty straightforward setup. MDX handles the content, build scripts organize it, and Next.js serves it up fast.