Engineering

Building a Blog Without a CMS: Next.js + Markdown

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:

PurposeChoiceAlternatives considered and rejected
FrameworkNext.js 15 App RouterAstro (lighter SSG but weaker ecosystem for dynamic needs), Nuxt (no Vue background on the team)
StylingTailwind CSSCSS-in-JS (runtime overhead, hostile to static generation)
Markdown parsinggray-matter + react-markdownMDX (overkill for text-only content, unnecessary JS bundle), marked (rendering is not composable)
GFM extensionremark-gfmWithout it, tables, task lists, and strikethrough simply don't render — this is required, not optional
DeploymentVercelDeep 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

  1. Create a .md file in content/posts/, write frontmatter + body
  2. git commit && git push
  3. 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 in public/, 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 .md file 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.