How I Structure a Next.js and MDX Personal Blog
A code-backed tour of how this blog turns MDX files into article routes, metadata, discovery pages, RSS, and a reviewable publishing workflow.
Sources
Article details
Context for reading and verifying this note.
Creation context: AI assisted with research, structure, or drafting. Rizky Romadon reviewed the sources, technical claims, examples, and final publishing decision.
This Article Describes the Site You Are Reading
This blog is both a writing space and a small software project. The articles live beside the application code, publishing happens through Git, and the production build proves that every public article can be parsed and rendered.
That statement is not an abstract recommendation. It describes the current repository behind this site.
The core architecture is deliberately small:
content/posts/*.mdx
|
v
src/lib/posts.ts
|
+--> /blog/[slug]
+--> /blog and category/tag pages
+--> /sitemap.xml
+--> /rss.xml
+--> home and related-article sectionsOne normalized Post model connects those surfaces. I do not maintain a separate CMS record, search document, RSS entry, and sitemap row for the same article.
Why I Chose Files Instead of a CMS
MDX fits how I already work as an engineer. An article is a text file that can be reviewed, diffed, versioned, and validated in the same workflow as code.
That gives me several concrete advantages:
- The full editorial history is in Git.
- A draft can remain in the repository without entering public routes.
- Metadata and internal links can be checked before publication.
- Code examples do not need to pass through a rich-text editor.
- The content model can evolve with TypeScript.
- A production build catches malformed MDX and route-generation problems.
The trade-off is equally concrete. This workflow assumes the writer is comfortable with Markdown, frontmatter, Git, and local validation. A newsroom with many non-technical editors would likely benefit from a CMS interface, roles, previews, scheduled publication, and asset management.
My decision is therefore not “MDX is better than a CMS.” It is “MDX has the lower coordination cost for one technical writer who already works in a code repository.”
The Article File Is the Source of Truth
Every article starts with YAML frontmatter followed by MDX content. A simplified current example is:
title: "RAG vs Fine-Tuning for Business Data"
slug: "rag-vs-fine-tuning-business-data"
description: "A practical framework for choosing RAG, fine-tuning, SQL, or a hybrid architecture."
date: "2026-07-18"
updated: "2026-07-20"
category: "AI"
tags:
- "RAG"
- "Spring AI"
author: "Rizky Romadon"
draft: false
aiAssisted: true
sourceUrls:
- "https://docs.spring.io/spring-ai/reference/api/retrieval-augmented-generation.html"
coverImage: "/images/generated/rag-vs-fine-tuning-business-data-hero.jpg"The fields do more than decorate a page.
| Field | Runtime responsibility | Editorial responsibility |
|---|---|---|
slug | Stable route and canonical URL | Avoid changing it after publication |
description | Cards and search/social metadata | Accurately summarize the article |
category, tags | Discovery and related-post scoring | Use a consistent vocabulary |
draft | Exclude unfinished posts publicly | Keep publication intentional |
aiAssisted | Show creation-context disclosure | Be honest about substantial assistance |
sourceUrls | Render supporting sources | Use sources that support actual claims |
coverImage | Hero and social image | Keep crop, subject, and alternative text useful |
The content model is intentionally boring. Predictability makes automation safer.
The Loader Turns Loose Files Into a Typed Model
The repository's src/lib/posts.ts file owns content normalization. It reads every .mdx file from content/posts, parses frontmatter with gray-matter, calculates reading time, extracts headings, normalizes category and tag slugs, and produces a Post object.
The public list is filtered here:
export function getAllPosts(options: { includeDrafts?: boolean } = {}) {
return fs
.readdirSync(postsDirectory)
.filter((fileName) => fileName.endsWith(".mdx"))
.map(parsePost)
.filter((post) => options.includeDrafts || !post.draft)
.sort((a, b) => postTimestamp(b) - postTimestamp(a));
}The important design decision is centralization. The homepage, archive, RSS route, sitemap, and category pages do not each decide what “published” means. They all call getAllPosts() and therefore receive the same draft filtering and normalized values.
I recently added a development-only exception for direct draft preview:
export function getPostBySlug(slug: string) {
return getAllPosts({
includeDrafts: process.env.NODE_ENV !== "production"
}).find((post) => post.slug === slugify(slug));
}That lets me open a draft route locally while production continues to hide it. The production behavior is enforced by code, not by remembering to avoid a URL.
One Dynamic Route Renders Every Article
Article pages live at src/app/blog/[slug]/page.tsx. The route uses generateStaticParams() to return published slugs during the build:
export function generateStaticParams() {
return getAllPosts().map((post) => ({ slug: post.slug }));
}Next.js documents generateStaticParams as the App Router mechanism for statically generating dynamic route segments at build time. For this blog, that means the build exercises each published article route before deployment.
The page then performs five responsibilities:
- Load the normalized post or return
notFound(). - Generate title, description, canonical, Open Graph, and Twitter metadata.
- Render MDX using
next-mdx-remote/rsc, GitHub-flavored Markdown, and code highlighting. - Build the table of contents from extracted headings.
- Add related, recent, previous, and next article navigation.
Keeping those responsibilities in one route is manageable because content parsing remains outside the React component.
Metadata Is Derived, Not Re-entered
The article route uses the same Post object to create display content and metadata. The canonical URL comes from the normalized slug, social descriptions come from description, dates come from date and updated, and the social image uses coverImage.
That prevents a common publishing error: the visible title says one thing while the canonical or Open Graph metadata describes an older draft.
The remaining risk is bad source metadata. Derivation keeps values consistent, but it cannot make an inaccurate description or misleading image useful. This is why metadata review belongs in the editorial checklist.
Discovery Surfaces Reuse the Same Collection
The same post collection supports several ways to browse:
/blogprovides search, category, tag, date, and featured filters./categories/[slug]groups articles around larger topics./tag/[tag]provides narrower discovery.- the homepage selects recent and featured articles;
- related-post scoring combines category and tag matches;
- adjacent navigation uses publication order.
This reuse is convenient, but it creates a taxonomy responsibility. A tag named AI Coding Agent and another named AI Coding Agents become separate discovery pages after slug normalization. I therefore treat categories and tags as controlled editorial data, even though they are stored as strings.
Empty categories are not generated by getAllCategories() because categories are derived from published posts. That keeps the sitemap from containing empty category routes.
RSS and Sitemap Are Projections of Published Content
The RSS route maps getAllPosts() into <item> elements with an escaped title, description, URL, publication date, and category.
The sitemap uses the same collection:
const postRoutes = getAllPosts().map((post) => ({
url: `${siteConfig.url}/blog/${post.slug}`,
lastModified: new Date(post.updated ?? post.date)
}));Next.js supports code-generated sitemap metadata through sitemap.ts. The useful part for this project is not the framework convention alone. It is that draft filtering happens before sitemap generation, so a draft is not advertised to crawlers while hidden from readers.
When I published the RAG article, the production build explicitly listed /blog/rag-vs-fine-tuning-business-data among the statically generated routes. That build output was concrete evidence that publication propagated through the content pipeline.
Cover Images Need a Two-Part Contract
Each generated article stores both a reproducible coverImagePrompt and the final coverImage path. The published RAG versus fine-tuning article is a concrete example of that pair in this repository.
The prompt specifies:
- 16:9 landscape at 1200×675;
- the focal point within the central safe area;
- low-detail edges for responsive
object-covercropping; - no readable text, logos, trademarks, or watermarks.
The final raster asset is saved in public/images/generated and referenced by frontmatter. This separation matters because the prompt documents how the image was produced, while the file gives the page a stable production asset.
If coverImage is missing, the loader can create a fallback cover. That is useful during drafting, but I do not want a text-heavy fallback to become the permanent hero for a generated article. Publication includes inspecting the real image at the final aspect ratio.
My Publishing Gate Is a Build, Not a File Save
Creating an MDX file does not make it ready. The editorial boundary follows the same principle as my AI-assisted engineering workflow: generation or drafting is separate from human authorization to publish. My current handoff checks include:
| Check | Failure it catches |
|---|---|
| Frontmatter parse | Invalid YAML or missing normalized values |
| Metadata review | Misleading titles, descriptions, dates, or image paths |
| Internal-link validation | Links to renamed or nonexistent slugs |
| Source review | Citations that do not support the associated claims |
git diff --check | Whitespace and patch formatting problems |
npm run typecheck | TypeScript integration regressions |
npm run build | MDX parsing, static routes, and production integration |
| Local visual review | Cropping, hierarchy, code blocks, and mobile readability |
The production build is especially valuable because it exercises content as application input. A malformed article is not merely a writing problem; it can be a failed deployment.
Where This Architecture Will Stop Scaling
This design is a good fit while one technical author owns the workflow. I would reconsider it if the site needed:
- multiple editors with different permissions;
- scheduled and embargoed publication;
- browser-based collaborative editing;
- a large reusable media library;
- translation workflows;
- frequent content updates independent of application deployment;
- editorial approvals that should not require Git access.
At that point, a headless CMS could become the better source of truth while Next.js remains the rendering layer. The current architecture does not need to pretend it solves that future problem.
What I Would Keep If the Storage Changed
Even if MDX files were replaced by a CMS, I would keep the underlying contracts:
- one normalized post model;
- one definition of published versus draft;
- metadata derived from reviewed content;
- stable slugs and canonical URLs;
- automated sitemap and RSS projections;
- source and AI-assistance fields;
- production validation before release.
Those are content-system decisions rather than MDX features.
Closing Notes
The useful part of this blog architecture is not that it avoids a database. It is that one reviewable source flows through a small number of explicit transformations.
MDX gives me ownership, Next.js gives the content predictable routes and metadata, and the production build creates a publishing gate I understand. For this stage of the site, that combination keeps the writing portable and the software honest about what it publishes.