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:
| Script | Output | What it does |
|---|---|---|
build-posts.mjs | src/lib/posts-discovered.ts | Extracts metadata from every MDX file |
build-posts.mjs | src/lib/posts-loaders.ts | Writes out the import() maps |
build-gallery.mjs | src/lib/gallery-discovered.ts | Finds 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:
- Write
<slug>.en.mdxand<slug>.zh.mdxinsidesrc/posts/. Add yourmetadataexport and any gallery images using<img data-gallery />. - Run
pnpm manifest:buildto generate the TypeScript manifests. - Next.js builds the static pages using those manifests.
opennextjs-cloudflarepackages 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.