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 version | MDX version | Gatsby | Upgrade shape |
|---|---|---|---|
| v1.x | MDX 1 | 2/3 | Content edits likely: MDX 1 was lenient |
| v2.x | MDX 1 | 3/4 | Mostly config + query changes |
| v3.x | MDX 2 | 4/5 | Config + query changes |
| v4.x | MDX 3 | 5 | Already 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 meansgatsby-remark-imagesis no longer in the pipeline. - Heading anchors: your table of contents and any external deep links depend on the slug algorithm.
rehype-slugandgatsby-remark-autolink-headersdo not always agree on punctuation, and changed anchors are broken inbound links. - RSS:
gatsby-plugin-feedqueriesmdx { body }in most starters and will now returnnull. Switch it toexcerptandhtmlfromchildMdx, or generate the feed fromrawMarkdownBody. - 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.