MDX Blog Platform Implementation Plan
**Goal:** Replace the static external blog links in this Next.js 16.2 portfolio with a first-class internal blog built around local MDX content, rich article layouts, interactive content blocks, and import/export workflows for HTML, Markdown, and MDX.
Architecture: Follow the official Next.js App Router MDX guide: configure @next/mdx in next.config.ts, add the required root mdx-components.tsx, store posts as content/blog/<slug>/index.mdx, and load posts through server-side dynamic imports in app/blogs/[slug]/page.tsx. Use MDX named exports for metadata instead of runtime frontmatter, keep .md and .html as import/export formats at the tooling boundary, and render interactive article features through a controlled library of blog-specific React components.
Tech Stack: Next.js 16.2 App Router, React 19, TypeScript, @next/mdx, @mdx-js/react, @types/mdx, Node fs/promises, remark-gfm, rehype-slug, rehype-autolink-headings, rehype-pretty-code, reading-time, mermaid, turndown, rehype-stringify, remark-stringify
Decision Summary
This plan intentionally chooses MDX as the canonical source format.
Why:
- The Next.js MDX guide for App Router, last updated February 11, 2026, explicitly supports
@next/mdx, dynamic MDX imports, metadata exports, and a required rootmdx-components.tsx. - MDX is the only option here that cleanly supports:
- normal blog prose
- inline HTML when needed
- custom React components for interactive demos
- reusable presentational blocks like callouts, tabs, diagrams, and embeds
- modern code treatments without building a separate CMS
@next/mdxdoes not support frontmatter by default, so the cleanest App Router setup is to use named exports from each post module for metadata and load them through the server.
Pragmatic scope choice:
- Runtime source of truth: MDX files in the repo
- Supported import formats:
.mdx,.md,.html - Supported export formats:
.mdx,.html,.md - Important caveat: Markdown export is only fully lossless for posts that stay inside the Markdown subset. Any JSX-only interactive block needs either a fallback representation or an exporter warning.
Current Repo Reality
This plan is tailored to the code that already exists:
app/blogs/page.tsxrenders a static blog page fromapp/blogs/blogsData.ts.app/blogs/BlogItem.tsxlinks out to Notion with<a href target="_blank">.app/blogs/types.tsonly models a shallow card shape, not a real post.app/style.cssalready defines the portfolio's dark surface and gold accent system. Reuse those tokens for the article experience instead of introducing a second visual language.next.config.tscurrently allows wildcard remote images. Do not tighten that yet without auditing the rest of the app, because other sections still use remote assets.- The dev script uses
next dev --turbopack, so MDX plugin configuration must stay compatible with the current Next.js Turbopack rules.
Architecture Diagram
flowchart TD
A["Author writes or imports a post"] --> B["scripts/blog/import-post.ts"]
B --> C["content/blog/{slug}/index.mdx"]
B --> D["public/blogs/{slug}/*"]
C --> E["MDX module exports"]
E --> F["metadata export"]
E --> G["default MDX component"]
H["mdx-components.tsx"] --> G
I["app/components/blog/mdx/*"] --> H
D --> G
J["lib/blog/source.ts"] --> C
J --> F
J --> K["app/blogs/page.tsx"]
J --> L["generateStaticParams()"]
J --> M["generateMetadata()"]
G --> N["app/blogs/[slug]/page.tsx"]
L --> N
M --> N
C --> O["scripts/blog/export-post.ts"]
O --> P[".mdx export"]
O --> Q[".html export"]
O --> R[".md export with warnings for JSX-only blocks"]
Alignment With The Next.js MDX Guide
This plan follows the official guide directly:
- Use
@next/mdxand addpageExtensionsfor Markdown-aware builds. - Create the required root
mdx-components.tsx. - Use dynamic imports for post routes plus
generateStaticParams. - Set
dynamicParams = falsefor strict static blog routes. - Use named exports from MDX modules for metadata instead of relying on frontmatter at runtime.
- Keep remark/rehype plugins in
next.config.ts, using Turbopack-safe string plugin names and serializable options.
One deliberate adaptation:
- The guide shows
.mdand.mdxfile handling. For this project, keep the canonical stored post file asindex.mdxeven when content started as Markdown. This avoids dual runtime loading paths and keeps the feature set consistent.
Content Model
Recommended folder structure:
content/
blog/
using-web-workers-with-vite/
index.mdx
aws-custom-domain-cloudfront/
index.mdx
public/
blogs/
using-web-workers-with-vite/
cover.png
worker-flow.png
aws-custom-domain-cloudfront/
cover.svg
Each post should export metadata from the MDX module:
export const metadata = {
title: "Using Web Workers with Vite",
description: "Guide to implementing Web Workers in Vite for better performance",
date: "2024-12-19",
category: "Frontend",
tags: ["vite", "performance", "workers"],
published: true,
featured: true,
coverImage: "/blogs/using-web-workers-with-vite/cover.png",
coverAlt: "Web workers article cover",
excerpt: "A practical walkthrough for moving expensive work off the main thread.",
}
import { Callout, CodeTabs, LivePreview, MermaidDiagram } from "@/app/components/blog/mdx"
# Using Web Workers with Vite
<Callout tone="info">Move CPU-heavy work off the main thread early.</Callout>
<CodeTabs
html={`<button id="start">Start</button>`}
css={`button { background: #ffcc55; }`}
js={`document.getElementById("start")?.addEventListener("click", () => console.log("run"))`}
/>
<LivePreview
html={`<button id="start">Start</button>`}
css={`button { background: #ffcc55; color: #111; }`}
js={`document.getElementById("start")?.addEventListener("click", () => alert("Worker started"))`}
/>
<MermaidDiagram chart={`flowchart LR; MainThread --> Worker; Worker --> MainThread`} />
Normalized server-side metadata shape:
slugtitledescriptionexcerptdatecategorytagspublishedfeaturedcoverImagecoverAltreadingTime
Rich Content Requirements
The blog must support all of these without leaving the MDX path:
| Capability | Approach |
|---|---|
| Standard prose | Native MDX markdown |
| Inline HTML | Native MDX HTML support |
| Interactive React blocks | Import blog-specific client components into MDX |
| HTML/CSS/JS code snippets | Fenced blocks plus a CodeTabs component |
| Live frontend demos | LivePreview client component rendered in a sandboxed iframe |
| Mermaid diagrams | MermaidDiagram client component |
| Syntax-highlighted code | rehype-pretty-code plus custom pre rendering |
| Copy buttons and filenames | Custom CodeBlock wrapper in mdx-components.tsx |
| Images | next/image mapping from mdx-components.tsx |
| Tables and GFM | remark-gfm |
| Heading anchors and TOC | rehype-slug, rehype-autolink-headings, heading extraction in lib/blog/source.ts |
Security rule:
- Do not execute arbitrary inline
<script>tags directly in the article DOM. - Any HTML/CSS/JS demo that runs code should execute in a sandboxed preview component, not in the page shell.
Reading Experience
The target reading experience should feel modern without fighting the current portfolio design:
- Keep the existing dark panels and gold accent tokens from
app/style.css. - Add a dedicated article shell with:
- wide hero image
- date, category, tags, and reading time
- sticky desktop table of contents
- reading progress indicator
- copyable heading anchors
- strong code block styling
- previous/next or related posts
- Keep the list page lightweight:
- featured/newest ordering
- client-side tag/category filters
- optional search input if the post count grows
Import / Export Strategy
Treat import and export as tooling, not as alternate runtime renderers.
Import
.mdximport:- validate metadata block or convert frontmatter into metadata export
- copy to
content/blog/<slug>/index.mdx
.mdimport:- convert to
.mdx - keep content pure markdown unless enhanced later
- convert to
.htmlimport:- convert to markdown/MDX using
turndown - preserve unsupported sections as raw HTML blocks
- copy referenced images into
public/blogs/<slug>/
- convert to markdown/MDX using
Export
.mdxexport:- lossless copy of canonical source
.htmlexport:- server render the MDX post into static HTML
- inline article classes and resolved asset URLs
.mdexport:- convert markdown-compatible nodes directly
- emit warnings for JSX-only blocks such as
LivePreview,MermaidDiagram, or custom embeds
The exporter should fail loudly when a requested target format cannot faithfully represent the content.
Non-Goals
Not part of this plan:
- remote MDX fetched from third-party URLs
- a browser-based WYSIWYG editor
- raw HTML as a first-class runtime post format
- immediate migration to the Neon-backed admin architecture described in the separate admin design doc
Task 1: Enable MDX Infrastructure
Files:
- Modify:
package.json - Modify:
next.config.ts - Create:
mdx-components.tsx
Steps:
- Add the MDX packages from the Next.js guide:
@next/mdx,@mdx-js/loader,@mdx-js/react,@types/mdx. - Add content-processing packages required for this experience:
remark-gfm,rehype-slug,rehype-autolink-headings,rehype-pretty-code,reading-time,mermaid,turndown,rehype-stringify,remark-stringify. - Wrap
next.config.tswithcreateMDX(...). - Add
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"]. - Configure Turbopack-safe plugin names and serializable options.
- Create the required root
mdx-components.tsxfile even before article components are wired.
Task 2: Create The Blog Content Contract
Files:
- Create:
content/blog/.gitkeep - Create:
lib/blog/types.ts - Create:
lib/blog/source.ts - Create:
lib/blog/reading-time.ts
Steps:
- Define TypeScript types for
BlogPostMetadata,BlogPostSummary,BlogPostModule, andBlogTocItem. - Implement a server-only loader that reads
content/blog/*directories. - Standardize on
index.mdxas the canonical post file name. - Dynamically import the MDX module for metadata and rendered content.
- Read the raw source from disk to derive reading time and heading data.
- Reject missing required metadata early instead of letting the page fail later.
Task 3: Build The MDX Component Library
Files:
- Create:
app/components/blog/mdx/Callout.tsx - Create:
app/components/blog/mdx/CodeBlock.tsx - Create:
app/components/blog/mdx/CodeTabs.tsx - Create:
app/components/blog/mdx/LivePreview.tsx - Create:
app/components/blog/mdx/MermaidDiagram.tsx - Create:
app/components/blog/mdx/ArticleImage.tsx - Create:
app/components/blog/mdx/index.ts - Modify:
mdx-components.tsx - Modify:
app/style.css
Steps:
- Map default MDX tags like
img,a,pre,code,table, and headings to site-specific components. - Use
next/imagefor article images through the MDX component map. - Build a
CodeBlockshell with copy action, optional filename label, and line highlight styling. - Build
CodeTabsfor HTML/CSS/JS side-by-side examples. - Build
LivePreviewas a sandboxediframedemo surface for frontend snippets. - Build
MermaidDiagramas a client component so authors can embed diagrams from MDX. - Extend
app/style.csswith clearly scoped.blog-article*and.blog-prose*classes that reuse the current theme variables.
Task 4: Replace Static Blog Cards With Real Post Data
Files:
- Modify:
app/blogs/page.tsx - Modify:
app/blogs/BlogList.tsx - Modify:
app/blogs/BlogItem.tsx - Modify:
app/blogs/types.ts - Create:
app/blogs/BlogFilters.tsx
Steps:
- Remove the dependency on
app/blogs/blogsData.tsfrom the page route. - Load summaries from
lib/blog/source.tsinapp/blogs/page.tsx. - Change card links from external Notion URLs to internal
/blogs/[slug]. - Add tag/category filters that work entirely on local metadata.
- Preserve the current card layout feel unless a larger visual redesign is explicitly requested.
Task 5: Add The Article Route And Metadata
Files:
- Create:
app/blogs/[slug]/page.tsx - Create:
app/blogs/[slug]/not-found.tsx - Create:
app/components/blog/ArticleHeader.tsx - Create:
app/components/blog/ArticleBody.tsx - Create:
app/components/blog/ArticleToc.tsx - Create:
app/components/blog/ReadingProgress.tsx - Modify:
app/metadata.ts - Create:
app/sitemap.ts
Steps:
- Implement
generateStaticParamsfrom the post loader. - Set
dynamicParams = false. - Dynamically import the selected post module and render its default export inside the article shell.
- Use the metadata export for
generateMetadata. - Render hero, metadata row, tags, TOC, reading progress, and related posts.
- Add blog URLs to the sitemap.
Task 6: Build Import / Export CLI Commands
Files:
- Create:
scripts/blog/import-post.ts - Create:
scripts/blog/export-post.ts - Create:
scripts/blog/shared.ts - Create:
content/blog/_templates/post.template.mdx
Steps:
- Add an import command that accepts
--input,--slug, and--format. - Convert
.mdand.htmlinputs into the canonicalindex.mdxoutput. - Copy or normalize local image references into
public/blogs/<slug>/. - Add an export command that emits
.mdx,.html, or.md. - Warn or fail when JSX-only components cannot be faithfully downgraded to Markdown.
- Document example commands in the template header or a short README comment inside the scripts.
Task 7: Seed The Blog With Real Internal Posts
Files:
- Create:
content/blog/using-web-workers-with-vite/index.mdx - Create:
content/blog/aws-custom-domain-cloudfront/index.mdx - Create:
content/blog/upload-folder-images-to-s3/index.mdx - Create:
public/blogs/using-web-workers-with-vite/* - Create:
public/blogs/aws-custom-domain-cloudfront/* - Create:
public/blogs/upload-folder-images-to-s3/* - Modify:
app/blogs/blogsData.ts
Steps:
- Convert at least three current Notion-linked posts into local MDX posts.
- Move or replace remote cover images with repo-local assets where practical.
- Keep
blogsData.tsonly as a temporary migration shim if required during rollout. - Remove the shim completely once the page uses the MDX loader end-to-end.
Task 8: Verify The End-To-End Authoring Experience
Files:
- Modify as needed based on failures from previous tasks
Steps:
- Run
bun run lint. - Run
bun run build. - Run
bun run devand manually verify:/blogsloads internal cards/blogs/[slug]pages render correctly- MDX imports custom components
- code blocks and live previews behave correctly
- Mermaid diagrams render
- metadata and sitemap generation still work
- Import one
.htmlsample and one.mdsample through the CLI to validate the content pipeline. - Export one post to
.htmland.mdand verify the warning behavior for interactive components.
Implementation Notes That Matter
- Keep the runtime content path simple. A blog does not need a CMS to be good.
- Prefer metadata exports over frontmatter for the runtime path because that matches the official App Router MDX guidance more directly.
- Use frontmatter parsing only inside import tooling when converting external content.
- Keep the import/export scripts server-only and out of the client bundle.
- Avoid promising perfect Markdown export for JSX-heavy posts. That is not technically honest.
Recommended First Slice
If this plan is executed incrementally, do the first slice in this order:
- Enable MDX config and root
mdx-components.tsx. - Build
lib/blog/source.tsand one sample MDX post. - Add
/blogs/[slug]and swap/blogsto local metadata. - Add
CodeBlock,CodeTabs, andMermaidDiagram. - Add import/export scripts after the core reading flow is stable.