+1 (415) 779-8456

Upgrading MDX on a Gatsby Site: gatsby-plugin-mdx v4, MDX 3, and the ESM Wall

Markdown is where most legacy Gatsby sites are stuck. The content is fine — it is the toolchain around it that has moved on. gatsby-plugin-mdx v1/v2 pinned MDX 1, gatsby-remark-prismjs pinned a highlighter nobody maintains, and the remark/rehype ecosystem went ESM-only years ago while gatsby-config.js is still CommonJS. So npm install starts printing ERR_REQUIRE_ESM, the audit report grows, and the safest-looking move is to touch nothing.

This tutorial is the upgrade we actually run on client sites: MDX 1 or 2 to MDX 3 on gatsby-plugin-mdx v4, ESM-only remark and rehype plugins loaded from a CJS config, and Prism swapped for Shiki — without rewriting the content.

Assumed starting point: Gatsby 5, Node 20 or 22, content in .md/.mdx files sourced with gatsby-source-filesystem.

1. Find out which MDX you are actually on

npm ls gatsby-plugin-mdx @mdx-js/react @mdx-js/mdx gatsby-transformer-remark
node -p "require('./package.json').dependencies"

The version of gatsby-plugin-mdx tells you how much work this is:

Plugin versionMDX versionGatsbyUpgrade shape
v1.xMDX 12/3Content edits likely: MDX 1 was lenient
v2.xMDX 13/4Mostly config + query changes
v3.xMDX 24/5Config + query changes
v4.xMDX 35Already current

Two things break content between MDX 1 and 2/3, and they are worth grepping for before you install anything:

# HTML comments are no longer valid MDX
grep -rn "<!--" content/ --include=*.mdx
# unescaped curly braces are now expressions
grep -rn "{" content/ --include=*.mdx | grep -v "{/\*" | head -40

In MDX 2+, <!-- note --> is a parse error (use {/* note */}) and {anything} is evaluated as JavaScript. A stray { in prose — common in posts about code, CSS, or templating — will fail the build with an unhelpful message pointing at the wrong line. Escape it as \{ or wrap it in backticks.

2. Upgrade the plugin and the peer deps together

npm install gatsby-plugin-mdx@^4 @mdx-js/react@^3 @mdx-js/mdx@^3 \
  gatsby-source-filesystem@latest gatsby-plugin-sharp@latest gatsby-transformer-sharp@latest
npm uninstall gatsby-remark-prismjs prismjs

gatsby-plugin-mdx v4 requires @mdx-js/react 3 as a peer; mixing @mdx-js/react 1 with MDX 3 produces a page that renders every element as plain text with no error at all. Check for duplicates after installing:

npm ls @mdx-js/react

If two copies appear (usually because a theme or an old gatsby-theme-* package pulls its own), pin one with overrides in package.json:

{
  "overrides": {
    "@mdx-js/react": "^3.1.0"
  }
}

3. The ESM wall, and the two ways through it

Nearly every current remark/rehype plugin is ESM-only. gatsby-config.js is loaded with require, so this fails:

// gatsby-config.js — throws ERR_REQUIRE_ESM
const remarkGfm = require('remark-gfm');

You have two options. The clean one, if nothing else in your config depends on CJS, is to rename the file:

// gatsby-config.mjs
import remarkGfm from 'remark-gfm';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';

const config = {
  siteMetadata: {
    title: 'Example Co',
    siteUrl: 'https://www.example.com',
  },
  plugins: [
    {
      resolve: 'gatsby-plugin-mdx',
      options: {
        extensions: ['.mdx', '.md'],
        mdxOptions: {
          remarkPlugins: [remarkGfm],
          rehypePlugins: [rehypeSlug, [rehypeAutolinkHeadings, { behavior: 'wrap' }]],
        },
        gatsbyRemarkPlugins: [
          { resolve: 'gatsby-remark-images', options: { maxWidth: 1200 } },
        ],
      },
    },
    { resolve: 'gatsby-source-filesystem', options: { name: 'posts', path: `${process.cwd()}/content/posts` } },
    'gatsby-plugin-image',
    'gatsby-plugin-sharp',
    'gatsby-transformer-sharp',
  ],
};

export default config;

Note the two separate buckets. mdxOptions.remarkPlugins / rehypePlugins take real unified plugins. gatsbyRemarkPlugins takes the legacy gatsby-remark-* plugins, which are not unified plugins and still work through a compatibility shim — gatsby-remark-images is the one most sites cannot drop, because it is what rewrites image references into gatsby-plugin-image data.

Gatsby's ESM config support also covers gatsby-node.mjs and gatsby-config.mjs, but not gatsby-browser/gatsby-ssr in every version, and any file you convert must convert fully — no require calls left behind, and __dirname is gone (use process.cwd() or import.meta.url).

If you cannot convert the file — a plugin of yours needs CJS, or your CI runs an older Node — use a dynamic import instead and keep the config async:

// gatsby-config.js — CJS, async export
module.exports = async () => {
  const { default: remarkGfm } = await import('remark-gfm');
  const { default: rehypeSlug } = await import('rehype-slug');

  return {
    siteMetadata: { title: 'Example Co', siteUrl: 'https://www.example.com' },
    plugins: [
      {
        resolve: 'gatsby-plugin-mdx',
        options: {
          mdxOptions: { remarkPlugins: [remarkGfm], rehypePlugins: [rehypeSlug] },
        },
      },
    ],
  };
};

Gatsby awaits a function export of gatsby-config.js, which is the supported escape hatch for ESM-only dependencies. It is uglier, and it is the version that survives a mixed-age codebase.

4. Fix the GraphQL queries and the page templates

This is the part that surprises people: MDX 2+ no longer exposes a compiled body string you can hand to MDXRenderer. MDXRenderer is gone. The MDX content arrives as the template's children.

Before (plugin v1/v2):

import { MDXRenderer } from 'gatsby-plugin-mdx';

export default function PostTemplate({ data }) {
  return <MDXRenderer>{data.mdx.body}</MDXRenderer>;
}

export const query = graphql`
  query($id: String!) {
    mdx(id: { eq: $id }) {
      body
      frontmatter { title date }
    }
  }
`;

After (plugin v4):

import * as React from 'react';
import { graphql } from 'gatsby';
import Layout from '../components/layout';

export default function PostTemplate({ data, children }) {
  const { title, date } = data.mdx.frontmatter;
  return (
    <Layout>
      <h1>{title}</h1>
      <time dateTime={date}>{date}</time>
      {children}
    </Layout>
  );
}

export const query = graphql`
  query PostById($id: String!) {
    mdx(id: { eq: $id }) {
      frontmatter { title date }
      tableOfContents
      internal { contentFilePath }
    }
  }
`;

export const Head = ({ data }) => (
  <>
    <title>{data.mdx.frontmatter.title}</title>
  </>
);

And createPages has to point the component at the content file, which is how v4 knows which MDX to compile into the page:

// gatsby-node.js
const path = require('node:path');

const postTemplate = path.resolve('./src/templates/post.js');

exports.createPages = async ({ graphql, actions, reporter }) => {
  const { data, errors } = await graphql(`
    {
      allMdx {
        nodes {
          id
          frontmatter { slug }
          internal { contentFilePath }
        }
      }
    }
  `);
  if (errors) {
    reporter.panicOnBuild('MDX query failed', errors);
    return;
  }

  for (const node of data.allMdx.nodes) {
    actions.createPage({
      path: `/blog/${node.frontmatter.slug}`,
      component: `${postTemplate}?__contentFilePath=${node.internal.contentFilePath}`,
      context: { id: node.id },
    });
  }
};

That ?__contentFilePath= query string is mandatory in v4. Omit it and the page builds successfully with an empty body — no warning, no error, just a blank article. It is the single most common reason an MDX upgrade "works" in gatsby build and ships broken.

Shortcodes move too. Instead of a global MDXProvider in gatsby-browser.js only, wrap the rendered children in your layout so SSR and hydration agree:

import { MDXProvider } from '@mdx-js/react';
import CalloutBox from './callout-box';
import { Link } from 'gatsby';

const shortcodes = { CalloutBox, a: Link };

export default function Layout({ children }) {
  return <MDXProvider components={shortcodes}>{children}</MDXProvider>;
}

5. Replace Prism with Shiki at build time

gatsby-remark-prismjs is unmaintained and ships a highlighter that needs a CSS theme, a class-name convention, and a runtime for line highlighting. rehype-pretty-code (built on Shiki) does the highlighting during the build and emits inline styles, so there is no highlighting CSS to maintain and no flash of unstyled code:

// gatsby-config.mjs
import rehypePrettyCode from 'rehype-pretty-code';

const prettyCodeOptions = {
  theme: { dark: 'github-dark-dimmed', light: 'github-light' },
  keepBackground: false,
  defaultLang: 'plaintext',
};

// ...inside gatsby-plugin-mdx options
mdxOptions: {
  remarkPlugins: [remarkGfm],
  rehypePlugins: [[rehypePrettyCode, prettyCodeOptions]],
},

Two practical notes. Shiki loads WASM-based grammars, which adds a few seconds to a cold build and much less once Gatsby's cache is warm — if your build time matters, restrict languages with langs: ['js', 'jsx', 'ts', 'bash', 'json', 'graphql']. And with keepBackground: false you supply the pre background yourself, which is what you want if you have a dark-mode toggle: emit both themes and switch with a [data-theme] selector rather than re-highlighting on the client.

Remove the old Prism stylesheet import from gatsby-browser.js in the same commit, or you will ship both.

6. Build clean, then diff the HTML

Cached MDX nodes from the old plugin will produce confusing failures. Always start from empty:

npm run clean   # gatsby clean
npx gatsby build

Then compare before and after properly, rather than clicking around. Keep a copy of the old public/ and diff the text content of a sample of pages:

# in the old checkout
npx gatsby build && cp -r public /tmp/public-old

# after the upgrade
npx gatsby build
for p in blog/first-post blog/post-with-shortcodes blog/post-with-images; do
  diff <(sed -e 's/<[^>]*>/ /g' "/tmp/public-old/$p/index.html" | tr -s ' \n' ' ') \
       <(sed -e 's/<[^>]*>/ /g' "public/$p/index.html"          | tr -s ' \n' ' ') \
    && echo "OK   $p" || echo "DIFF $p"
done

Stripping tags and collapsing whitespace means markup changes (new heading anchors, Shiki spans) do not drown out what you care about: missing paragraphs, missing images, a shortcode that silently rendered nothing. Pick pages deliberately — the longest post, one with each custom shortcode, one with images, one with a table, one with footnotes.

Then check the pages that are easy to forget:

  • Images: grep -c "gatsby-image-wrapper" public/blog/*/index.html — a zero here means gatsby-remark-images is no longer in the pipeline.
  • Heading anchors: your table of contents and any external deep links depend on the slug algorithm. rehype-slug and gatsby-remark-autolink-headers do not always agree on punctuation, and changed anchors are broken inbound links.
  • RSS: gatsby-plugin-feed queries mdx { body } in most starters and will now return null. Switch it to excerpt and html from childMdx, or generate the feed from rawMarkdownBody.
  • Footnotes: MDX 3 renders GFM footnotes with different ids and an <h2 class="sr-only"> footnote label. Style it or it appears as a stray heading.

7. Know when not to do this

If the site is content-frozen, has no security surface beyond a static build, and nobody is adding MDX, the honest recommendation is to leave the toolchain pinned and spend the budget on something a reader will notice. The upgrade is worth doing when one of these is true: you need a current remark/rehype plugin, the build is failing on a newer Node, an audit finding traces to the MDX chain, or you are heading for a migration off Gatsby and want the content layer portable first. That last one matters more than it sounds — MDX 3 plus standard remark plugins is close to what Astro and Next.js consume, so this upgrade turns into a head start rather than throwaway work.

Budget roughly half a day for a small blog on plugin v3, and two to three days for a v1-era site with custom shortcodes and a pinned theme. Most of the time goes into content edits from MDX 1's leniency, not into config.

If you have a Gatsby site whose markdown pipeline has stopped taking upgrades — or you want the content layer made portable before a replatform — get in touch and we will take a look at the repo.