Back to all posts
Frontend11 min read

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 root mdx-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/mdx does 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.tsx renders a static blog page from app/blogs/blogsData.ts.
  • app/blogs/BlogItem.tsx links out to Notion with <a href target="_blank">.
  • app/blogs/types.ts only models a shallow card shape, not a real post.
  • app/style.css already 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.ts currently 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

mermaid
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/mdx and add pageExtensions for Markdown-aware builds.
  • Create the required root mdx-components.tsx.
  • Use dynamic imports for post routes plus generateStaticParams.
  • Set dynamicParams = false for 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 .md and .mdx file handling. For this project, keep the canonical stored post file as index.mdx even when content started as Markdown. This avoids dual runtime loading paths and keeps the feature set consistent.

Content Model

Recommended folder structure:

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

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

  • slug
  • title
  • description
  • excerpt
  • date
  • category
  • tags
  • published
  • featured
  • coverImage
  • coverAlt
  • readingTime

Rich Content Requirements

The blog must support all of these without leaving the MDX path:

CapabilityApproach
Standard proseNative MDX markdown
Inline HTMLNative MDX HTML support
Interactive React blocksImport blog-specific client components into MDX
HTML/CSS/JS code snippetsFenced blocks plus a CodeTabs component
Live frontend demosLivePreview client component rendered in a sandboxed iframe
Mermaid diagramsMermaidDiagram client component
Syntax-highlighted coderehype-pretty-code plus custom pre rendering
Copy buttons and filenamesCustom CodeBlock wrapper in mdx-components.tsx
Imagesnext/image mapping from mdx-components.tsx
Tables and GFMremark-gfm
Heading anchors and TOCrehype-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

  • .mdx import:
    • validate metadata block or convert frontmatter into metadata export
    • copy to content/blog/<slug>/index.mdx
  • .md import:
    • convert to .mdx
    • keep content pure markdown unless enhanced later
  • .html import:
    • convert to markdown/MDX using turndown
    • preserve unsupported sections as raw HTML blocks
    • copy referenced images into public/blogs/<slug>/

Export

  • .mdx export:
    • lossless copy of canonical source
  • .html export:
    • server render the MDX post into static HTML
    • inline article classes and resolved asset URLs
  • .md export:
    • 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:

  1. Add the MDX packages from the Next.js guide: @next/mdx, @mdx-js/loader, @mdx-js/react, @types/mdx.
  2. 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.
  3. Wrap next.config.ts with createMDX(...).
  4. Add pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"].
  5. Configure Turbopack-safe plugin names and serializable options.
  6. Create the required root mdx-components.tsx file 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:

  1. Define TypeScript types for BlogPostMetadata, BlogPostSummary, BlogPostModule, and BlogTocItem.
  2. Implement a server-only loader that reads content/blog/* directories.
  3. Standardize on index.mdx as the canonical post file name.
  4. Dynamically import the MDX module for metadata and rendered content.
  5. Read the raw source from disk to derive reading time and heading data.
  6. 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:

  1. Map default MDX tags like img, a, pre, code, table, and headings to site-specific components.
  2. Use next/image for article images through the MDX component map.
  3. Build a CodeBlock shell with copy action, optional filename label, and line highlight styling.
  4. Build CodeTabs for HTML/CSS/JS side-by-side examples.
  5. Build LivePreview as a sandboxed iframe demo surface for frontend snippets.
  6. Build MermaidDiagram as a client component so authors can embed diagrams from MDX.
  7. Extend app/style.css with 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:

  1. Remove the dependency on app/blogs/blogsData.ts from the page route.
  2. Load summaries from lib/blog/source.ts in app/blogs/page.tsx.
  3. Change card links from external Notion URLs to internal /blogs/[slug].
  4. Add tag/category filters that work entirely on local metadata.
  5. 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:

  1. Implement generateStaticParams from the post loader.
  2. Set dynamicParams = false.
  3. Dynamically import the selected post module and render its default export inside the article shell.
  4. Use the metadata export for generateMetadata.
  5. Render hero, metadata row, tags, TOC, reading progress, and related posts.
  6. 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:

  1. Add an import command that accepts --input, --slug, and --format.
  2. Convert .md and .html inputs into the canonical index.mdx output.
  3. Copy or normalize local image references into public/blogs/<slug>/.
  4. Add an export command that emits .mdx, .html, or .md.
  5. Warn or fail when JSX-only components cannot be faithfully downgraded to Markdown.
  6. 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:

  1. Convert at least three current Notion-linked posts into local MDX posts.
  2. Move or replace remote cover images with repo-local assets where practical.
  3. Keep blogsData.ts only as a temporary migration shim if required during rollout.
  4. 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:

  1. Run bun run lint.
  2. Run bun run build.
  3. Run bun run dev and manually verify:
    • /blogs loads 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
  4. Import one .html sample and one .md sample through the CLI to validate the content pipeline.
  5. Export one post to .html and .md and 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.

If this plan is executed incrementally, do the first slice in this order:

  1. Enable MDX config and root mdx-components.tsx.
  2. Build lib/blog/source.ts and one sample MDX post.
  3. Add /blogs/[slug] and swap /blogs to local metadata.
  4. Add CodeBlock, CodeTabs, and MermaidDiagram.
  5. Add import/export scripts after the core reading flow is stable.