How to compile and run MDX in React

Sandro Maglione

Sandro Maglione

Software development

It took me a long time to understand how to compile mdx and run it with React, without using any bundler, nextjs, or whatever.

Why? I want full control over how, when, where my mdx content is compiled. Just "importing mdx" and using it as a component is too "magic".

This guide uses only the mdx compiler to convert and run mdx content with React πŸ’‘

pnpm add @mdx-js/mdx

@mdx-js/mdx is all that you need, this is how πŸ‘‡


How mdx becomes React code

mdx content is just plain text (a string).

source.mdx
Some **mdx** content

@mdx-js/mdx is a compiler that takes a string and outputs some javascript code.

πŸ‘‰ Nothing more, nothing less πŸ‘ˆ

All the "magic" is inside the compiler:

  • Allow to provide custom components
  • Convert markdown to jsx
  • Run custom plugins (e.g. code highlight)

All we need to care is compiling and running the javascript output.

This is what an mdx bundler does: it takes care of the compilation step without you noticing, and just provides you with a runnable component

Compiling mdx to javascript

@mdx-js/mdx provides a compile function.

compile takes some mdx content (a string in the example) and some options.

To allow executing the resulting javascript we need to specify the output format as function-body πŸ‘‡

import { compile } from "@mdx-js/mdx";

const make = (content: string) =>
  compile(content, {
    outputFormat: "function-body",
    remarkPlugins: [],
    rehypePlugins: [],
  });

compile returns a VFile.

globalThis.String converts a VFile it to executable javascript.

import { compile } from "@mdx-js/mdx";

const make = (content: string) =>
  compile(content, {
    outputFormat: "function-body",
    remarkPlugins: [],
    rehypePlugins: [],
  });

const mdxToJavascript = (content: string): string =>
  globalThis.String(
    make(content)
  );

For example the following .mdx content is compiled to:

source.mdx
Some **mdx** content
"use strict";
const {jsx: _jsx, jsxs: _jsxs} = arguments[0];
function _createMdxContent(props) {
  const _components = {
    p: "p",
    strong: "strong",
    ...props.components
  };
  return _jsxs(_components.p, {
    children: ["Some ", _jsx(_components.strong, {
      children: "mdx"
    }), " content"]
  });
}
function MDXContent(props = {}) {
  const {wrapper: MDXLayout} = props.components || ({});
  return MDXLayout ? _jsx(MDXLayout, {
    ...props,
    children: _jsx(_createMdxContent, {
      ...props
    })
  }) : _createMdxContent(props);
}
return {
  default: MDXContent
};

Important: The result of mdxToJavascript is a plain string.

Without "function-body" the same mdx is compiled to:

import {jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
function _createMdxContent(props) {
  const _components = {
    p: "p",
    strong: "strong",
    ...props.components
  };
  return _jsxs(_components.p, {
    children: ["Some ", _jsx(_components.strong, {
      children: "mdx"
    }), " content"]
  });
}
export default function MDXContent(props = {}) {
  const {wrapper: MDXLayout} = props.components || ({});
  return MDXLayout ? _jsx(MDXLayout, {
    ...props,
    children: _jsx(_createMdxContent, {
      ...props
    })
  }) : _createMdxContent(props);
}

Running mdx in React

We now have a string of javascript code that we want to execute in React.

@mdx-js/mdx provides a run function to do just that.

run returns the result of executing the compiled javascript: an object containing a default value that represent the runnable component.

import { run } from "@mdx-js/mdx";
import * as runtime from "react/jsx-runtime";

export default async function MdxComponent() {
  const { default: MDXContent } = await run(
    compiledMdx, /// πŸ‘ˆ Your compiled mdx content from before (`compile`)
    { ...runtime }
  );

  return (
    <MDXContent
      components={{
        /// πŸͺ„ Provide custom React components to MDX
      }}
    />
  );
}

There is more.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 700+ readers.

Complete example: mdx and effect with custom plugin (shiki)

Here is the code I am using in my app to compile mdx:

  • @mdx-js/mdx: Mdx compiler and runner
  • effect: Reusable services
  • shiki: Code blocks highlighter

First I created a service to highlight code using shiki:

This allows to create the highlighter only once and use it multiple times using Layer from effect πŸͺ„

import { Context, Effect, Layer } from "effect";
import { getHighlighter } from "shiki";

const make = Effect.promise(() =>
  getHighlighter({ themes: ["one-dark-pro"], langs: ["ts"] })
);

export class ShikiHighlighter extends Context.Tag("ShikiHighlighter")<
  ShikiHighlighter,
  Effect.Effect.Success<typeof make>
>() {
  static readonly Live = Layer.effect(this, make);
}

I use this in another service that implements a custom rehype plugin to highlight code blocks:

import { Context, Effect, Layer } from "effect";
import type { Root } from "hast";
import { toString as hastToString } from "hast-util-to-string";
import { visit } from "unist-util-visit";
import * as ShikiHighlighter from "./ShikiHighlighter";

const make = Effect.map(
  ShikiHighlighter.ShikiHighlighter, /// πŸ‘ˆ Dependency on `ShikiHighlighter`
  (highlighter) => () => (tree: Root) => {
    visit(tree, "element", (node, index) => {
      if (node.tagName === "pre") {
        const code = node.children[0];
        if (code.type === "element" && code.tagName === "code") {
          const codeString = hastToString(node);
          const hastCode = highlighter.codeToHast(codeString, {
            theme: "one-dark-pro",
            lang: "ts",
          });

          const pre = hastCode.children[0];
          if (pre.type === "element" && pre.tagName === "pre") {
            node.properties = pre.properties;
            node.children = pre.children;
          }
        }
      }
    });
  }
);

export class ShikiPlugin extends Context.Tag("ShikiPlugin")<
  ShikiPlugin,
  Effect.Effect.Success<typeof make>
>() {
  static readonly Live = Layer.effect(this, make).pipe(
    Layer.provide(ShikiHighlighter.ShikiHighlighter.Live)
  );
}

Finally, I provide the plugin to an Mdx services that executes compile from @mdx-js/mdx:

import { compile } from "@mdx-js/mdx";
import { Context, Data, Effect, Layer } from "effect";
import * as ShikiPlugin from "./ShikiPlugin";

export class MdxCompileError extends Data.TaggedError("MdxCompileError")<
  Readonly<{
    error: unknown;
  }>
> {}

const make = Effect.map(
  ShikiPlugin.ShikiPlugin, /// πŸ‘ˆ Provide custom plugin
  (plugin) => (content: string) =>
    Effect.tryPromise({
      try: () =>
        compile(content, {
          rehypePlugins: [plugin],
          outputFormat: "function-body",
        }),
      catch: (error) => new MdxCompileError({ error }),
    })
);

export class Mdx extends Context.Tag("Mdx")<
  Mdx,
  Effect.Effect.Success<typeof make>
>() {
  static readonly Live = Layer.effect(this, make).pipe(
    Layer.provide(ShikiPlugin.ShikiPlugin.Live)
  );
}

Now I can use the Mdx service to compile any string to javascript and run it as a React component πŸͺ„


This setup allows full control over your mdx content.

You can read a list of mdx files from any source (file system, remote, database) and use the Mdx service to convert it and run it in React πŸš€

If you are interested to learn more, follow me on Twitter at @SandroMaglione and subscribe to my newsletter here below πŸ‘‡

Thanks for reading!

πŸ‘‹γƒ»Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 700+ readers.