The content for this publication lives in content/posts as MDX files. There’s no database, no admin panel, and no hosted CMS. Publishing is a commit.

I want to be honest about the tradeoff rather than pitch it. I can’t write from my phone, there’s no media library, and if I ever have several writers this breaks down. What I get in exchange is that my posts are diffable text files next to the code that renders them, and the entire publishing surface is something I can read in an afternoon.

Beyond Next.js and React, that takes six packages.

What each one is for

gray-matter splits a file into frontmatter and content. It’s the piece that turns the YAML block at the top of a post into an object.

next-mdx-remote compiles MDX at request or build time. I’m using its RSC entry point, which renders MDX in a server component — so the compiler stays on the server and no MDX runtime ships to the browser.

remark-gfm adds the GitHub-flavoured markdown I assume by habit: tables, strikethrough, task lists, autolinks. Without it, a table in a post renders as a line of pipes.

rehype-slug adds an id to every heading, which is what makes a table of contents and shareable deep links possible.

rehype-pretty-code handles syntax highlighting. It runs at build time using the same engine as VS Code, so highlighted code arrives as plain styled markup with no client-side highlighter.

That last point is the pattern across all of these: every one of them does its work before the page reaches a browser.

The frontmatter is the schema

With no CMS there’s no form validation, so the loader validates instead. Required fields are checked, and anything unexpected throws:

const required = [
  "title",
  "description",
  "slug",
  "publishedAt",
  "category",
  "type",
] as const;
 
for (const field of required) {
  if (!data[field]) {
    throw new Error(`Missing "${field}" in ${filePath}`);
  }
}
 
if (!isTopicSlug(data.category)) {
  throw new Error(`Invalid category in ${filePath}`);
}

Because posts are read during the build, a throw here fails the build and names the file. A typo in a category, a missing description, a forgotten date — none of them can reach production, and I find out in seconds rather than by noticing a broken card later.

This is the part of a CMS I actually missed, and it turned out to be about fifteen lines.

Reading the files once

Next.js renders many routes from the same set of posts — the homepage, two listing pages, topic pages, every article, the sitemap, the feed. Reading the directory from disk for each of those would be wasteful, so the loader is wrapped in React’s cache:

export const getAllPosts = cache(loadAllPosts);

Now the files are parsed once per render pass and every caller shares the result. The functions above it stay simple, because filtering an in-memory array is cheap and the expensive part happens once.

What the posts don't do

MDX allows importing components into content, and I’ve kept that almost entirely unused. The only custom components are the ones for links, images, and code, applied automatically to standard markdown.

That’s a deliberate constraint. The moment a post depends on a bespoke component, that post is coupled to this codebase, and I can’t move it, reuse it, or read it comfortably as text. Keeping posts to ordinary markdown means the writing stays portable, and it keeps me from treating a new component as a substitute for writing more clearly.

Six packages, one directory of text files, and a build that refuses to ship a malformed post. If I outgrow it, the content is plain markdown and moves anywhere.