Skip to content

Fix: MDX Not Working — Components Not Rendering, Imports Failing, or Frontmatter Not Parsed

FixDevs ·

Quick Answer

How to fix MDX issues — Next.js App Router setup with @next/mdx and next-mdx-remote, custom component mapping, frontmatter parsing with gray-matter, remark and rehype plugins, and TypeScript configuration.

The Problem

A React component inside an MDX file doesn’t render:

import { Button } from '../components/Button';

# My Post

<Button>Click me</Button>
Error: Cannot use import statement in a module

Or frontmatter values are undefined even though they’re defined in the file:

---
title: My Article
date: 2024-03-15
---

# {frontmatter.title}
// In the page component
const { title } = frontmatter;  // undefined

Or custom components passed via components prop render as plain HTML instead:

<MDXContent components={{ h1: CustomHeading }} />
// <h1> tags in MDX still render as default HTML

Or the MDX file fails to parse with a remark/rehype plugin error:

Error: Cannot find module 'remark-gfm'

Why This Happens

MDX is a compilation step, not a runtime feature. MDX files are transformed to JavaScript during the build or at request time. The exact behavior depends heavily on which MDX integration you’re using:

  • @next/mdx compiles at build time — MDX files are treated as pages or components. Imports work natively. But frontmatter is not automatically extracted — you handle it separately.
  • next-mdx-remote and @mdx-js/mdx compile at runtime — useful for MDX stored in a database or CMS. The compilation happens server-side and the result is serialized to the client. This is what makes dynamic frontmatter extraction possible.
  • Component substitution requires the components prop — Recharts passes down to every MDX-rendered element. Missing a components prop means MDX falls back to default HTML elements and your custom components are ignored.
  • Remark/rehype plugins must be ESM-compatibleremark-gfm v3+, rehype-highlight, and most modern plugins are pure ESM. Using require() or misconfigured next.config.js (CommonJS) causes import failures.

Fix 1: Set Up MDX with Next.js App Router

Option A — @next/mdx (build-time, file-based):

npm install @next/mdx @mdx-js/loader @mdx-js/react
npm install --save-dev @types/mdx
// next.config.mjs
import createMDX from '@next/mdx';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';

const withMDX = createMDX({
  options: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [rehypeHighlight],
  },
});

export default withMDX({
  pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx'],
});
// app/mdx-components.tsx — required for App Router
// This file maps HTML elements to custom React components
import type { MDXComponents } from 'mdx/types';
import Link from 'next/link';

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return {
    h1: ({ children }) => (
      <h1 className="text-4xl font-bold mt-8 mb-4">{children}</h1>
    ),
    h2: ({ children }) => (
      <h2 className="text-3xl font-semibold mt-6 mb-3">{children}</h2>
    ),
    a: ({ href, children }) => (
      <Link href={href ?? '#'} className="text-blue-600 underline">
        {children}
      </Link>
    ),
    code: ({ children }) => (
      <code className="bg-gray-100 rounded px-1 py-0.5 font-mono text-sm">
        {children}
      </code>
    ),
    // Merge with any components passed at the call site
    ...components,
  };
}
// app/blog/my-post/page.mdx — MDX page in App Router
import { Button } from '@/components/Button';

export const metadata = {
  title: 'My Post',
  description: 'A great post',
};

# Hello World

This is an MDX page with a React component:

<Button variant="primary">Click me</Button>

Option B — next-mdx-remote (runtime, CMS/database-driven):

npm install next-mdx-remote gray-matter
// lib/mdx.ts — read and compile MDX from the filesystem or database
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { compileMDX } from 'next-mdx-remote/rsc';
import remarkGfm from 'remark-gfm';
import { Button } from '@/components/Button';
import { Callout } from '@/components/Callout';

// Components available in MDX files
const components = { Button, Callout };

export async function getMDXContent(slug: string) {
  const filePath = path.join(process.cwd(), 'content', `${slug}.mdx`);
  const source = fs.readFileSync(filePath, 'utf-8');

  // compileMDX handles both content and frontmatter
  const { content, frontmatter } = await compileMDX<{
    title: string;
    date: string;
    tags: string[];
  }>({
    source,
    options: {
      parseFrontmatter: true,
      mdxOptions: {
        remarkPlugins: [remarkGfm],
      },
    },
    components,
  });

  return { content, frontmatter };
}
// app/blog/[slug]/page.tsx — App Router page
import { getMDXContent } from '@/lib/mdx';

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const { content, frontmatter } = await getMDXContent(params.slug);

  return (
    <article>
      <h1>{frontmatter.title}</h1>
      <time>{frontmatter.date}</time>
      <div className="prose">{content}</div>
    </article>
  );
}

Fix 2: Parse Frontmatter Correctly

Frontmatter access patterns vary by integration:

// With gray-matter (any integration)
import matter from 'gray-matter';
import fs from 'fs';

const source = fs.readFileSync('content/post.mdx', 'utf-8');
const { data: frontmatter, content } = matter(source);

// frontmatter = { title: 'My Post', date: '2024-03-15', tags: ['js'] }
// content = MDX content without the frontmatter block

// With next-mdx-remote compileMDX
const { frontmatter } = await compileMDX({
  source,
  options: { parseFrontmatter: true },
});

// With @next/mdx — frontmatter is NOT automatically extracted
// Export metadata instead:
// content/post.mdx with @next/mdx
export const metadata = {
  title: 'My Post',
  date: '2024-03-15',
};

# {metadata.title}

Post content here.
// Read metadata from @next/mdx files
// The metadata export is available as a module export
const { metadata } = await import(`../content/${slug}.mdx`);

Generate static params from MDX files:

// app/blog/[slug]/page.tsx
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';

const CONTENT_DIR = path.join(process.cwd(), 'content');

export async function generateStaticParams() {
  const files = fs.readdirSync(CONTENT_DIR);
  return files
    .filter(f => f.endsWith('.mdx'))
    .map(f => ({ slug: f.replace('.mdx', '') }));
}

export async function generateMetadata({ params }: { params: { slug: string } }) {
  const source = fs.readFileSync(
    path.join(CONTENT_DIR, `${params.slug}.mdx`), 'utf-8'
  );
  const { data } = matter(source);
  return { title: data.title, description: data.description };
}

Fix 3: Custom Component Mapping

Map HTML elements and custom components inside MDX:

// components/MDXComponents.tsx
import Image from 'next/image';
import Link from 'next/link';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';

// Code block with syntax highlighting
function CodeBlock({ children, className }: {
  children: string;
  className?: string;
}) {
  const language = className?.replace('language-', '') ?? 'text';

  return (
    <SyntaxHighlighter language={language} style={oneDark} showLineNumbers>
      {children}
    </SyntaxHighlighter>
  );
}

// Callout component (usable directly in MDX)
function Callout({ type = 'info', children }: {
  type: 'info' | 'warning' | 'danger';
  children: React.ReactNode;
}) {
  const styles = {
    info: 'bg-blue-50 border-blue-400 text-blue-800',
    warning: 'bg-yellow-50 border-yellow-400 text-yellow-800',
    danger: 'bg-red-50 border-red-400 text-red-800',
  };

  return (
    <div className={`border-l-4 p-4 rounded my-4 ${styles[type]}`}>
      {children}
    </div>
  );
}

export const mdxComponents = {
  // HTML element overrides
  h1: ({ children }: any) => <h1 className="text-4xl font-bold">{children}</h1>,
  h2: ({ children }: any) => <h2 className="text-3xl font-semibold">{children}</h2>,
  a: ({ href, children }: any) => <Link href={href}>{children}</Link>,
  img: ({ src, alt }: any) => (
    <Image src={src} alt={alt ?? ''} width={800} height={400} className="rounded" />
  ),
  pre: ({ children }: any) => <>{children}</>,
  code: CodeBlock,
  // Custom components (available without importing in MDX files)
  Callout,
};
// Use in next-mdx-remote
const { content } = await compileMDX({
  source,
  components: mdxComponents,
  options: { parseFrontmatter: true },
});

// Use in app/mdx-components.tsx for @next/mdx
import { mdxComponents } from '@/components/MDXComponents';

export function useMDXComponents(components: MDXComponents): MDXComponents {
  return { ...mdxComponents, ...components };
}
// In MDX files — custom components work without importing
# My Post

<Callout type="warning">
  This feature is experimental.
</Callout>

Fix 4: Configure Remark and Rehype Plugins

Plugins extend MDX parsing and output:

npm install remark-gfm remark-toc rehype-slug rehype-autolink-headings rehype-pretty-code
// next.config.mjs — ESM required for most modern plugins
import createMDX from '@next/mdx';
import remarkGfm from 'remark-gfm';
import remarkToc from 'remark-toc';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypePrettyCode from 'rehype-pretty-code';

const withMDX = createMDX({
  options: {
    remarkPlugins: [
      remarkGfm,                    // GitHub Flavored Markdown (tables, strikethrough, etc.)
      [remarkToc, { heading: 'Contents', maxDepth: 3 }],
    ],
    rehypePlugins: [
      rehypeSlug,                   // Add `id` to headings
      [rehypeAutolinkHeadings, { behavior: 'wrap' }],
      [rehypePrettyCode, {
        theme: 'github-dark',
        keepBackground: false,
      }],
    ],
  },
});

export default withMDX({ pageExtensions: ['ts', 'tsx', 'md', 'mdx'] });

Plugin compatibility notes:

  • remark-gfm v3+ is ESM-only — use import, not require
  • rehype-pretty-code replaces rehype-highlight for Shiki-based highlighting
  • rehype-prism-plus is an alternative that works with Prism themes

Fix 5: TypeScript Configuration

MDX TypeScript support requires type declarations:

// types/mdx.d.ts
declare module '*.mdx' {
  import type { MDXProps } from 'mdx/types';
  import type { ReactElement } from 'react';

  export default function MDXContent(props: MDXProps): ReactElement;
  export const metadata: Record<string, unknown>;
}
// tsconfig.json — ensure MDX files are included
{
  "compilerOptions": {
    "jsx": "preserve",
    "moduleResolution": "bundler",
    "allowJs": true,
    "strict": true
  },
  "include": ["**/*.ts", "**/*.tsx", "**/*.mdx"]
}

Type-safe frontmatter with next-mdx-remote:

// Define frontmatter schema
interface PostFrontmatter {
  title: string;
  description: string;
  date: string;
  tags: string[];
  draft?: boolean;
}

// Pass as type parameter to compileMDX
const { content, frontmatter } = await compileMDX<PostFrontmatter>({
  source,
  options: { parseFrontmatter: true },
  components,
});

// frontmatter is now fully typed
frontmatter.title;    // string
frontmatter.draft;    // boolean | undefined

Fix 6: MDX with Astro and Other Frameworks

// Astro — MDX is built-in via @astrojs/mdx
// astro.config.mjs
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import remarkGfm from 'remark-gfm';

export default defineConfig({
  integrations: [
    mdx({
      remarkPlugins: [remarkGfm],
      extendMarkdownConfig: true,  // Inherit Markdown config
    }),
  ],
});
// src/pages/blog/[slug].astro
---
import { getCollection } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('blog');
  return posts.map(post => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content, headings } = await post.render();
---

<article>
  <h1>{post.data.title}</h1>
  <Content />
</article>
// Remix — use @mdx-js/rollup
// vite.config.ts
import mdx from '@mdx-js/rollup';
import remarkGfm from 'remark-gfm';

export default defineConfig({
  plugins: [
    mdx({ remarkPlugins: [remarkGfm] }),
    remix(),
  ],
});

Still Not Working?

Cannot use import statement in a module in Next.js — your next.config.js is using CommonJS (module.exports) but remark/rehype plugins are ESM. Rename next.config.js to next.config.mjs and use import/export syntax throughout. If you can’t switch to ESM, use dynamic import() in an async config function.

MDX component renders as [object Object] or throws — the components object was passed incorrectly. When using compileMDX, components go in the third argument object: { components }. When using MDXRemote (client-side), the components prop is on the component itself: <MDXRemote source={source} components={components} />.

Custom h1, h2 components work but code doesn’t — code blocks in MDX render as <pre><code>. If you only map code, inline code works but fenced code blocks may not (they come wrapped in pre). You need to map both pre and code, or use a plugin like rehype-pretty-code that transforms the structure before your component mapping runs.

Frontmatter dates become strings instead of Date objectsgray-matter parses YAML, which converts date strings to JavaScript Date objects. But JSON serialization (when passing data between server and client) converts Date back to strings. Serialize dates explicitly: date: frontmatter.date.toISOString() or store them as strings in your frontmatter (date: '2024-03-15').

For related content issues, see Fix: Next.js App Router Not Working and Fix: Astro Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles