A document editor on a laptop with a large red Maine Coon cat resting nearby on a stack of books.

MDXEditor: WYSIWYG Markdown That Stays Markdown

The Orange Cat
The Orange Cat

If you have ever asked a non-developer to edit content in a "Markdown editor," you have probably watched the same moment of confusion: they see ## Heading and **bold** and wonder why the words look broken. Most React Markdown editors are really just a textarea bolted to a live preview pane. That works beautifully for developers and terribly for everyone else.

MDXEditor takes a different path. It is a React component that renders a genuine WYSIWYG editing surface: you type, and bold looks bold, headings look like headings, tables become editable grids, and code blocks light up with syntax highlighting. Yet the document it produces is still clean Markdown, not opaque HTML or a proprietary JSON blob. That means your output stays diff-friendly, git-friendly, and portable. Its standout trick is first-class MDX support, so embedded JSX components can be edited inline with generated property editors, something almost no other editor does well.

What Makes It Tick

MDXEditor stands on some serious shoulders. The editing core is Lexical, Meta's extensible text-editor framework, which handles selection, nodes, undo/redo, and commands. On top of that, MDXEditor layers Markdown and MDX import/export using the unified, micromark, and mdast ecosystem, so it can parse Markdown into an AST, map it into Lexical nodes, and serialize it back out without losing your MDX or JSX. CodeMirror 6 powers the source view, the side-by-side diff view, and code-block editing. Accessible UI primitives come from Radix UI, and a reactive state system called Gurx drives the plugin architecture.

That plugin architecture is the most important thing to understand. The <MDXEditor> component is almost empty by default. Every feature, including headings, lists, links, tables, and toolbars, is opt-in through a plugins array. This keeps the API composable and lets you tree-shake whatever you do not use.

Here is a quick tour of what is on offer:

  • WYSIWYG editing of headings, bold, italic, underline, blockquotes, and thematic breaks.
  • Lists including bulleted, numbered, and task/checkbox lists.
  • Links with a dialog and autocomplete, plus images with custom upload handlers.
  • Editable GFM tables and code blocks with per-block language selection and syntax highlighting.
  • YAML frontmatter editing and a diff/source mode for power users.
  • MDX/JSX components edited inline, plus directives and admonitions (:::note callouts).
  • Markdown shortcuts so typing # becomes a heading, and an imperative ref API for reading and writing content.

Getting It Into Your Project

Install the package with your manager of choice.

npm install @mdxeditor/editor
yarn add @mdxeditor/editor

There is one detail that trips up nearly everyone on day one: you must also import the stylesheet. Without @mdxeditor/editor/style.css, the editor renders completely unstyled and looks broken. Keep that import close to your component so you never forget it.

Your First Editor

The smallest possible editor needs two things: the component itself and at least one plugin. Features you do not register simply will not exist, so even basic headings require the headingsPlugin.

import { MDXEditor, headingsPlugin } from '@mdxeditor/editor'
import '@mdxeditor/editor/style.css'

export default function App() {
  return (
    <MDXEditor
      markdown={'# Hello World\n\nStart typing here.'}
      plugins={[headingsPlugin()]}
    />
  )
}

In practice you will want a richer baseline. A typical content editor combines headings, lists, quotes, thematic breaks, and the Markdown shortcut plugin so that typing common syntax auto-formats as you go.

import {
  MDXEditor,
  headingsPlugin,
  listsPlugin,
  quotePlugin,
  thematicBreakPlugin,
  markdownShortcutPlugin,
} from '@mdxeditor/editor'
import '@mdxeditor/editor/style.css'

export default function ContentEditor() {
  return (
    <MDXEditor
      markdown="# Welcome\n\n- write\n- naturally"
      plugins={[
        headingsPlugin(),
        listsPlugin(),
        quotePlugin(),
        thematicBreakPlugin(),
        markdownShortcutPlugin(),
      ]}
    />
  )
}

With markdownShortcutPlugin active, typing ## produces a level-two heading, - starts a bullet list, and > begins a blockquote. The author never has to know they are writing Markdown.

Building a Toolbar

A WYSIWYG editor without buttons feels unfinished. The toolbarPlugin lets you assemble a toolbar from individual, composable button components inside a toolbarContents render function.

import {
  MDXEditor,
  toolbarPlugin,
  headingsPlugin,
  UndoRedo,
  BoldItalicUnderlineToggles,
  BlockTypeSelect,
} from '@mdxeditor/editor'
import '@mdxeditor/editor/style.css'

export default function EditorWithToolbar() {
  return (
    <MDXEditor
      markdown="Hello world"
      plugins={[
        headingsPlugin(),
        toolbarPlugin({
          toolbarClassName: 'my-toolbar',
          toolbarContents: () => (
            <>
              <UndoRedo />
              <BoldItalicUnderlineToggles />
              <BlockTypeSelect />
            </>
          ),
        }),
      ]}
    />
  )
}

There is a subtle dependency to keep in mind: most toolbar buttons need their backing feature plugin to also be present. BlockTypeSelect only offers heading options if headingsPlugin() is in the array, a table-insert button needs the tables plugin, and so on. If a button seems to do nothing when clicked, the missing plugin is almost always the culprit. Toolbar components fail silently rather than throwing, so pair every button with its feature.

Reading and Writing Content

MDXEditor manages its own internal state, which is great for performance because you are not re-rendering on every keystroke. To get the content out, you reach for the imperative ref API exposing getMarkdown, setMarkdown, and insertMarkdown.

import { useRef } from 'react'
import { MDXEditor, headingsPlugin, type MDXEditorMethods } from '@mdxeditor/editor'
import '@mdxeditor/editor/style.css'

export default function RefDrivenEditor() {
  const ref = useRef<MDXEditorMethods>(null)

  return (
    <>
      <button onClick={() => ref.current?.setMarkdown('# Replaced content')}>
        Set
      </button>
      <button onClick={() => console.log(ref.current?.getMarkdown())}>
        Get
      </button>
      <button onClick={() => ref.current?.insertMarkdown('**inserted** ')}>
        Insert at cursor
      </button>
      <MDXEditor
        ref={ref}
        markdown="hello world"
        onChange={(md) => console.log(md)}
        plugins={[headingsPlugin()]}
      />
    </>
  )
}

The onChange callback fires with the current Markdown string whenever the document changes, which is handy for autosave or live word counts. For most forms, though, the cleaner pattern is to skip per-keystroke state and call getMarkdown() once on submit.

Taming Server-Side Rendering

MDXEditor is a browser-only component and does not server-render. In frameworks like the Next.js App Router, that means you cannot import it directly into a server component. The documented pattern uses two files. First, an initialized client component that holds your plugin configuration and forwards a ref.

'use client'
import {
  MDXEditor,
  headingsPlugin,
  listsPlugin,
  type MDXEditorMethods,
  type MDXEditorProps,
} from '@mdxeditor/editor'
import '@mdxeditor/editor/style.css'
import { type ForwardedRef } from 'react'

export default function InitializedMDXEditor({
  editorRef,
  ...props
}: { editorRef: ForwardedRef<MDXEditorMethods> | null } & MDXEditorProps) {
  return (
    <MDXEditor
      plugins={[headingsPlugin(), listsPlugin()]}
      ref={editorRef}
      {...props}
    />
  )
}

Then a wrapper that loads it dynamically with SSR disabled, while still re-forwarding the ref so the parent can call getMarkdown().

'use client'
import dynamic from 'next/dynamic'
import { forwardRef } from 'react'
import { type MDXEditorMethods, type MDXEditorProps } from '@mdxeditor/editor'

const Editor = dynamic(() => import('./InitializedMDXEditor'), { ssr: false })

export const ForwardRefEditor = forwardRef<MDXEditorMethods, MDXEditorProps>(
  (props, ref) => <Editor {...props} editorRef={ref} />
)
ForwardRefEditor.displayName = 'ForwardRefEditor'

If you are on the Next.js Pages Router instead, add transpilePackages: ['@mdxeditor/editor'] to your config and enable topLevelAwait in the webpack settings. Either way, because the dependency tree is large (Lexical, CodeMirror, Radix, and the full unified stack add up to hundreds of kilobytes gzipped), lazy-loading is not just an SSR workaround, it is good bundle hygiene. Load the editor on the screens that need it rather than shipping it in your main bundle.

Editing Live MDX Components

The feature that earns MDXEditor its name is MDX support. By registering the JSX plugin and describing your components, authors can insert and edit real React components inside the document, with form-based property editors generated automatically.

import {
  MDXEditor,
  headingsPlugin,
  jsxPlugin,
  type JsxComponentDescriptor,
} from '@mdxeditor/editor'
import '@mdxeditor/editor/style.css'

const jsxComponentDescriptors: JsxComponentDescriptor[] = [
  {
    name: 'Callout',
    kind: 'flow',
    source: './components/Callout',
    props: [
      { name: 'tone', type: 'string' },
      { name: 'title', type: 'string' },
    ],
    hasChildren: true,
    Editor: () => <div>Callout component</div>,
  },
]

export default function MdxAwareEditor() {
  return (
    <MDXEditor
      markdown={'# Docs\n\n<Callout tone="info" title="Heads up">Body</Callout>'}
      plugins={[headingsPlugin(), jsxPlugin({ jsxComponentDescriptors })]}
    />
  )
}

Each descriptor tells the editor how to recognize a component, what props it accepts, and how to render its in-editor representation. The result round-trips perfectly back to .mdx source, which is exactly what makes MDXEditor such a strong fit for docs platforms and headless CMS authoring where developers consume the MDX that non-developers produce.

A Note on Version 4

If you are upgrading an existing project, version 4.0.0 brought one breaking change worth flagging. The Sandpack plugin was removed entirely, so sandpackPlugin, SandpackEditor, the related signals like insertSandpack$, and the toolbar components such as InsertSandpack are all gone. Live code-running blocks are no longer bundled. Regular syntax-highlighted code blocks powered by CodeMirror remain fully supported, so most projects only need to drop the Sandpack-specific imports.

When to Reach for It

MDXEditor is the right tool when you need non-technical users to author content without ever seeing raw syntax, but you still want clean Markdown or MDX coming out the other end. It shines when you need live embedded JSX components edited inline, and when you want a batteries-included experience (toolbar, tables, images, highlighted code blocks, frontmatter, diff mode) without assembling a Lexical or ProseMirror setup yourself.

It is a full authoring surface, not a renderer, and it carries real weight in your bundle, so it is not the answer for simply displaying Markdown. For that, a lightweight renderer is a better fit. But for the actual editing experience, where polish and accessibility matter and your output needs to stay in version control, MDXEditor delivers a remarkably complete package. It is one of the very few React editors that is genuinely WYSIWYG and genuinely MDX-aware at the same time, and that combination is hard to find anywhere else.