Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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 compile-time format, not a runtime parser. Every .mdx file goes through a pipeline: parse to MDAST, run remark plugins, convert to HAST, run rehype plugins, then output JavaScript that React can render. Most “MDX not working” reports come from misunderstanding which stage of that pipeline owns the problem you are seeing. If components do not render, the problem is at the JSX-output stage. If imports fail, the issue is in the bundler config that wraps the MDX output. If frontmatter is missing, it never entered the pipeline because the integration you chose does not parse it.

The integration you pick determines the rules. @next/mdx runs at build time and treats .mdx as React component modules — imports work natively, exports work natively, but frontmatter is just YAML to the compiler unless a remark plugin extracts it. next-mdx-remote runs at request or build time, ships compiled MDX as serialized JSX, and supports parseFrontmatter: true as a flag because the source is read from disk or CMS before compilation. Astro’s @astrojs/mdx is a different beast — it integrates with Content Collections and parses frontmatter through Zod schemas. Mixing examples across these three is the leading cause of “I followed a tutorial and it broke.”

The third major failure mode is plugin order. Remark runs first (markdown -> MDAST), rehype runs second (HTML AST). If you add rehype-slug before rehype-autolink-headings, the autolink plugin has no IDs to link to. If you add remark-gfm after a plugin that already transformed the AST to HTML, GFM tables silently do nothing. The pipeline is unidirectional and plugins do not warn when they receive input they cannot process. MDX v3’s ESM-only break compounds this — older CommonJS-built plugin wrappers throw on import inside a next.config.js that was not migrated to .mjs.

  • @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 — components are passed through the MDX runtime and substituted for HTML elements. 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.
  • Plugin order is significant — rehype slug must run before autolink headings, remark math must run before rehype katex, remark frontmatter must run before any rehype plugin that touches the YAML node.

Diagnostic Timeline

The reflex when MDX breaks is to grep for “import” and start changing paths. That hides the real problem most of the time. Walk the pipeline.

Minute 0 — observe. Open the rendered page in dev mode and inspect the DOM. If your custom component renders as plain text like <Callout> literally appearing in the output, the MDX compiler never saw it as JSX — usually because the file extension is .md instead of .mdx, or because the bundler is treating it as a string. If the component renders as default <h1> instead of your styled version, the components prop never reached the render call.

Minute 3 — confirm the integration. Print which integration handles the file. In Next.js, log inside app/mdx-components.tsx to verify useMDXComponents is even being called. If it is not, @next/mdx is not registered in next.config.mjs, or pageExtensions does not include 'mdx'. With next-mdx-remote, log inside the compileMDX call to verify components are being passed as the third option. A surprising number of bugs are “you wrote {components} instead of components.”

Minute 6 — check ESM vs CJS. Rename next.config.js to next.config.mjs and switch module.exports to export default. If the bundler crashes with “Cannot use import statement outside a module,” your remark/rehype plugins are ESM but your config is not. This is the single most common MDX v3 upgrade break.

Minute 9 — verify plugin order. If headings have no id attributes, rehype-slug either is not in the pipeline or runs after rehype-autolink-headings. If GFM tables render as plain text, remark-gfm is missing from remarkPlugins. If frontmatter shows up as visible text at the top of the page, remark-frontmatter or the integration’s parseFrontmatter: true flag is missing.

Minute 13 — inspect the compiled output. With next-mdx-remote, log the compiled content before returning it. The output should be a React element tree, not a string. If it is a string, you forgot to render it as {content} and instead are templating it as text. With @next/mdx, build to disk and grep .next/server/app/<route>/page.js for your custom component name — if it is missing, the component prop was never wired in.

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').

Image from next/image throws inside MDXnext/image requires explicit width and height props. Markdown ![alt](src) syntax produces an <img> with no dimensions, which crashes the substituted component. Either wrap your img mapping in a component that supplies defaults, switch to next-image plugins like next-image-export-optimizer, or write <Image> directly in MDX where you control the props.

HMR does not pick up MDX edits — Vite-based setups (Astro, SolidStart) hot-reload .mdx files reliably. Next.js with @next/mdx sometimes caches the compiled output and only picks up edits on full reload. Add mdx to the watchOptions in your build config, or restart the dev server after changing remark/rehype plugin lists — plugin changes are not watched, only file content is.

compileMDX from next-mdx-remote/rsc returns serialized output that crashes in client componentsnext-mdx-remote/rsc is for React Server Components only. Inside a 'use client' component, import from next-mdx-remote (not the /rsc subpath) and use <MDXRemote> with serialized source from serialize() on the server. Mixing the two paths produces a hydration mismatch that is hard to trace.

For related content issues, see Fix: Astro Content Collections Not Working, Fix: Next.js Build Failed, Fix: Contentlayer Not Working, and Fix: Vite Failed to Resolve Import.

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