When I set up this blog, I didn't plug in any CMS. Not because of some "keep it simple" mantra — but because of a durability calculation: a technical post stays relevant for 5–10 years, while the average SaaS product lifespan is shorter. Locking content inside a third-party database is a deprecation risk that compounds over time.
Architecture Decisions
Stack:
| Purpose | Choice | Alternatives considered and rejected |
|---|---|---|
| Framework | Next.js 15 App Router | Astro (lighter SSG but weaker ecosystem for dynamic needs), Nuxt (no Vue background on the team) |
| Styling | Tailwind CSS | CSS-in-JS (runtime overhead, hostile to static generation) |
| Markdown parsing | gray-matter + react-markdown | MDX (overkill for text-only content, unnecessary JS bundle), marked (rendering is not composable) |
| GFM extension | remark-gfm | Without it, tables, task lists, and strikethrough simply don't render — this is required, not optional |
| Deployment | Vercel | Deep Next.js integration, zero-config edge distribution |
The key call: react-markdown's components prop decouples styling from the Markdown source entirely. The .md files stay clean text; all layout logic lives in the component layer. This is lighter than MDX, where every post becomes its own JS module and pays the full module-graph cost at build time.
Content Parsing
// lib/posts.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
const postsDirectory = path.join(process.cwd(), "content/posts");
function parsePost(slug: string) {
const file = fs.readFileSync(
path.join(postsDirectory, `${slug}.md`), "utf8"
);
const { data, content } = matter(file);
return { slug, ...data, content };
}
gray-matter was chosen because its frontmatter parser has the best error tolerance and gives accurate line numbers on YAML syntax errors. The filename doubles as the slug — no mapping layer, which means one less abstraction that accumulates complexity as the post count grows.
parsePost runs server-side at build time; it never ships to the browser bundle. fs is automatically excluded from the client by Next.js boundary rules — no manual configuration needed.
Static Generation
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
App Router's generateStaticParams with the default dynamicParams = false means every post page is generated at build time. An unrecognised slug at runtime returns 404 immediately. No fallback logic, no runtime ISR complexity.
A note on ISR (Incremental Static Regeneration): ISR needs a persistent cache layer and revalidation logic. A personal blog doesn't update frequently enough to justify that overhead — I write a post, git push, and wait ~30 seconds for the build. ISR makes sense for high-traffic sites with rapid content turnover, not this.
Markdown Rendering and Style Injection
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h2: ({ children }) => (
<h2 className="font-serif-sc text-[24px] ...">{children}</h2>
),
p: ({ children }) => (
<p className="mb-7 [&:first-of-type]:first-letter:...">
{children}
</p>
),
// ... remaining elements
}}
>
{post.content}
</ReactMarkdown>
The components mapping means the same Markdown source can support multiple visual themes — change the component layer and the posts look different without touching a single .md file. This is an underappreciated advantage of the file-based approach: content and presentation are actually separated.
remark-gfm is a hard dependency, not a nice-to-have. Without it, tables, strikethrough, and task lists simply don't work — GFM is the de facto standard now, and any parser that doesn't support it owes you an explanation.
SEO
export async function generateMetadata({ params }): Promise<Metadata> {
const post = getPostBySlug(params.slug);
return {
title: `${post.title} — kindredzhang`,
description: post.description,
};
}
generateMetadata runs at build time and writes static <head> tags into the HTML. Search engines get complete metadata on the first crawl, no hydration wait. This is one of the core SSR/SSG advantages over pure CSR, but in Next.js it's so foundational it barely deserves its own section.
Writing Workflow
- Create a
.mdfile incontent/posts/, write frontmatter + body git commit && git push- Vercel builds automatically, ~30 seconds to live
Git is the version control. There is no "draft" concept — unfinished posts live on a branch, main only holds published content. This is more reliable than a CMS draft state: a CMS draft can be accidentally published; a Git branch switch cannot.
Boundaries
Where this falls down:
- Multi-author workflows (Git conflicts need manual resolution, no visual diff UI)
- Media-heavy content (Git is hostile to binaries; large image sets bloat
git clone. My workaround: images live inpublic/, posts only store path references) - Full-text search (a build-time search index is doable but requires maintaining a separate JSON index file. At low post counts, Ctrl+F is sufficient)
Where this genuinely beats a CMS:
- Zero external API dependencies. Build failures have exactly one cause: your code is wrong. A CMS adds API rate limits, schema changes, and service sunsets to the failure matrix.
- Maximum content portability. A
.mdfile imports into any static site generator without migration scripts. - Predictable build times. No external API latency in the critical path; CI duration scales linearly with post count only.
Closing
A CMS turns a blog into an application (backend, database, API). A file-based approach turns it back into documents. For someone whose job is producing content, the latter is the more honest abstraction.