Building a Blog with MDX and React Router v7

ENGINEERING
Christof Zirkler
December 29, 2024

After migrating from a Next.js blog to React Router v7, I'm quite happy with how the MDX setup turned out. Here's what worked well and what didn't.

What is MDX?

MDX combines Markdown with JSX. Write articles in Markdown, embed React components when needed, and keep everything in your repo for version control. It's simple, and it works.

How MDX Compiles

Here's the compilation pipeline:

  1. MDX Files (.mdx) → Vite's MDX plugin processes them at build time
  2. Compiled to JSX → Each MDX file becomes a React component
  3. Server-Side Rendering → React Router renders components to HTML on the server
  4. Client Hydration → React hydrates the HTML on the client

Build Pipeline Diagram

┌─────────────────────────────────────────────────────────────────┐
│                        BUILD TIME (Vite)                        │
└─────────────────────────────────────────────────────────────────┘

  ┌──────────────┐
  │  .mdx files  │
  │  in content/ │
  └──────┬───────┘
         │
         │ import.meta.glob() discovers all .mdx files
         │
         ▼
  ┌─────────────────────────────────────────────────────────┐
  │  @mdx-js/rollup plugin processes each file:             │
  │                                                         │
  │  1. remark (markdown parsing)                           │
  │     └─ remarkGfm adds GFM support                       │
  │                                                         │
  │  2. rehype (HTML processing)                            │
  │     └─ rehypePrism adds syntax highlighting             │
  │                                                         │
  │  3. MDX → JSX compilation                               │
  └─────────────────────────────────────────────────────────┘
         │
         │ Each .mdx becomes a module with:
         │ - default export: React component (content)
         │ - meta export: metadata object
         │
         ▼
  ┌──────────────────────┐
  │  Compiled JS modules │
  │  (eager: true)       │
  └──────────┬───────────┘
             │
             │
┌────────────┴───────────────────────────────────────────────────┐
│                    RUNTIME (Server)                             │
└────────────┬───────────────────────────────────────────────────┘
             │
             │ getAllArticles() extracts metadata
             │
             ▼
  ┌──────────────────────┐
  │  Route loader finds  │
  │  article by slug     │
  └──────────┬───────────┘
             │
             │ Route component finds matching module
             │
             ▼
  ┌──────────────────────┐
  │  React Router SSR    │
  │  renders component   │
  │  → HTML              │
  └──────────┬───────────┘
             │
             │
┌────────────┴───────────────────────────────────────────────────┐
│                    RUNTIME (Client)                            │
└────────────┬───────────────────────────────────────────────────┘
             │
             │ HTML sent to browser
             │
             ▼
  ┌──────────────────────┐
  │  React hydrates      │
  │  the HTML            │
  └──────────────────────┘

The MDX plugin (@mdx-js/rollup) transforms your Markdown into React components. When you write:

## Heading

Some text with **bold** and *italic*.

It becomes JSX that React can render. The compilation happens at build time, so there's no runtime overhead for parsing Markdown.

The Setup

app/content/articles/
  ├── article-slug.mdx
public/content/articles/
  └── images/

Vite processes MDX files at build time. We use import.meta.glob to discover articles automatically, and everything renders server-side. The implementation is straightforward—articles are just files in a directory.

Configuration

In vite.config.ts:

import mdx from "@mdx-js/rollup";
import remarkGfm from "remark-gfm";
import rehypePrism from "@mapbox/rehype-prism";

export default defineConfig({
  plugins: [
    mdx({
      remarkPlugins: [remarkGfm],
      rehypePlugins: [rehypePrism],
    }),
    // ... other plugins
  ],
});

The MDX plugin handles the compilation. remarkGfm adds GitHub Flavored Markdown support (tables, strikethrough, etc.), and rehypePrism provides syntax highlighting for code blocks.

Usage Examples

Basic Article Structure

Each article is an MDX file that exports metadata:

export const meta = {
  author: 'Christof Zirkler',
  date: '2024-12-29',
  title: 'My Article Title',
  description: 'Article description for SEO',
  tags: ['TECH', 'REACT'],
};

Then write your markdown content below. This gets compiled to JSX, which React renders to HTML on the server.

Code blocks with syntax highlighting work automatically—just use triple backticks with a language identifier like javascript, typescript, or markdown.

The meta export is extracted at build time and used for the blog listing. The markdown content becomes the default export—a React component.

Dynamic Article Discovery

Articles are discovered using Vite's import.meta.glob:

// In getAllArticles.server.ts
const articleModules = import.meta.glob("../content/articles/*.mdx", {
  eager: true,
});

This glob pattern finds all MDX files at build time. With eager: true, modules are imported immediately, making them available on both server and client.

Rendering Articles

In the route component, we find and render the article:

// In blog.$slug.tsx
const articleModules = import.meta.glob("../content/articles/*.mdx", {
  eager: true,
});

const ArticleComponent = useMemo(() => {
  const modulePath = Object.keys(articleModules).find((path) =>
    path.includes(`${article.slug}.mdx`)
  );
  return modulePath ? articleModules[modulePath]?.default : null;
}, [article.slug]);

return <ArticleComponent />;

The MDX file has already been compiled to a React component, so we just render it. React Router handles the server-side rendering, and the HTML is sent to the client.

What I Like About It

  • Developer Experience: Markdown is familiar, Git gives us version control, and IDEs provide proper syntax highlighting. Adding a new article is just creating a new file.

  • Performance: Articles are processed at build time, which means fast page loads. No database queries, no CMS overhead—just static content that renders quickly. The MDX-to-JSX compilation happens once during the build, not on every request.

  • Flexibility: Need a custom component in an article? Just use it. Want to change how articles are rendered? Modify the layout component. Everything is in your control.

  • SEO: Server-rendered content means search engines get the full content immediately. Each article exports its own metadata, so you have full control over titles and descriptions.

The Trade-offs

  • Build Time: More articles mean longer builds. Not a huge deal for our use case, but worth mentioning if you're planning on hundreds of articles. Each MDX file is compiled during the build process.

  • Developer Experience: Your team needs to be comfortable with Markdown and the file structure. Images require manual management. There's no WYSIWYG editor—content is code.

  • Scalability: This approach works great for dozens or hundreds of articles, but thousands would become unwieldy. You'll need to build search functionality yourself, and there's no built-in draft system.

  • Setup Complexity: The initial configuration isn't trivial. MDX plugins, TypeScript types, routing—it all needs to work together. But once it's set up, it's smooth sailing.

When It Makes Sense

This setup works well if:

  • Your team is comfortable with Markdown and code
  • You want performance and developer experience
  • You're managing a reasonable number of articles (hundreds, not thousands)
  • You don't need features like drafts, scheduling, or complex workflows
  • You want full control over how content is rendered
  • You're okay with build-time compilation (MDX → JSX happens during npm run build)

If you need non-technical writers or complex content workflows, consider a headless CMS instead. If you want file-based content with a UI, tools like Forestry or Netlify CMS might be a better fit.

Real-World Results

In practice, articles render in under 100ms, bundle sizes stay small (no CMS overhead), and deployments are straightforward since articles are part of the build. Server-rendered content also means good SEO out of the box.

The compilation process is transparent—MDX files become React components, which become HTML on the server. There's no runtime Markdown parsing, which keeps things fast.

Bottom Line

I'm happy with this approach. It's simple, fast, and gives us the flexibility we need. The file-based workflow fits our team perfectly, and the performance is exactly what we wanted. The trade-offs are worth it for a developer-focused blog.

Is it the right solution for everyone? Probably not. But for our use case, it's been a solid choice.