Next.js

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.

Rizky Romadon profile photoRizky Romadon··9 min read

Sources

Article details

Context for reading and verifying this note.

Updated Jul 20, 20269 min readNext.js4 sourcesAI-assisted, human-reviewed

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:

Prompt
content/posts/*.mdx
        |
        v
src/lib/posts.ts
        |
        +--> /blog/[slug]
        +--> /blog and category/tag pages
        +--> /sitemap.xml
        +--> /rss.xml
        +--> home and related-article sections

One 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:

YAML
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.

FieldRuntime responsibilityEditorial responsibility
slugStable route and canonical URLAvoid changing it after publication
descriptionCards and search/social metadataAccurately summarize the article
category, tagsDiscovery and related-post scoringUse a consistent vocabulary
draftExclude unfinished posts publiclyKeep publication intentional
aiAssistedShow creation-context disclosureBe honest about substantial assistance
sourceUrlsRender supporting sourcesUse sources that support actual claims
coverImageHero and social imageKeep 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:

TS
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:

TS
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:

TS
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:

  1. Load the normalized post or return notFound().
  2. Generate title, description, canonical, Open Graph, and Twitter metadata.
  3. Render MDX using next-mdx-remote/rsc, GitHub-flavored Markdown, and code highlighting.
  4. Build the table of contents from extracted headings.
  5. 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:

  • /blog provides 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:

TS
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-cover cropping;
  • 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:

CheckFailure it catches
Frontmatter parseInvalid YAML or missing normalized values
Metadata reviewMisleading titles, descriptions, dates, or image paths
Internal-link validationLinks to renamed or nonexistent slugs
Source reviewCitations that do not support the associated claims
git diff --checkWhitespace and patch formatting problems
npm run typecheckTypeScript integration regressions
npm run buildMDX parsing, static routes, and production integration
Local visual reviewCropping, 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.

Related posts

Related posts will appear after more articles are generated.