Skip to content

NEXT.JS

next-mdx-remote-client: Why Your MDX Attributes Vanished

next-mdx-remote 6.0.0 strips every {expression} attribute by default and logs nothing. Here is the option that causes it, the fork people migrate to, and which fix to pick.

Your <Figure width={1200} /> renders without a width because next-mdx-remote 6.0.0 deletes the attribute before compiling, and tells you nothing. The option responsible is blockJS, it defaults to true, it landed in the last release before the repository was archived, and it is the reason next-mdx-remote-client — a maintained fork — now draws four hundred thousand downloads a week. This walks the exact mechanism, the three ways out, and which one to pick, measured against next-mdx-remote 6.0.0 and next-mdx-remote-client 2.1.11 on 2026-08-11.

Key takeaways

  • The attribute is deleted, not passed as undefined. A remark plugin removes it from the syntax tree, so your component is called with the prop absent and no warning anywhere.
  • blockJS defaults to true as of 6.0.0, published 2026-02-12. Every {expression} in a JSX attribute and every expression in the body goes with it.
  • It is not an RSC behaviour. The pages-router serialize() strips the same attributes.
  • The upstream repository is archived and the npm package is not marked deprecated, so npm install next-mdx-remote warns about nothing while 66 open issues sit unanswerable.
  • Three fixes, in ascending cost: write string attributes, pass blockJS: false, or move to next-mdx-remote-client, which evaluates expressions and preserves their types.

What actually happens to your attribute

The failure is quiet enough to cost an afternoon, so it is worth seeing the whole of it. This site renders every article through next-mdx-remote, and its component map is the one described in building a modern Next.js site with AI assistance. Compile a single line of MDX and inspect what the component receives:

scripts/check-mdx-attrs.mjs
const FIXTURE = `<Figure src="cover.webp" alt="A" width={1200} height="630" priority={true} />`;

The props that arrive are these:

Terminal
node scripts/check-mdx-attrs.mjs
# → OPTIONS                    src     alt     width     height   priority
# → default (blockJS: true)    kept    kept    DROPPED   kept     DROPPED
# → blockJS: false             kept    kept    kept      kept     kept

Rendered for real through compileMDX, the component's own props object is {"height":"630","title":"t"} — the expression-valued keys are not present at all. That distinction matters when you debug it: a prop that arrives as undefined suggests a typo in the name, and you go looking in the wrong place. A prop that was never in the object suggests the value was wrong, and you go looking in the wrong place again.

The option that causes it

blockJS is a serialize() option added in 6.0.0. When true, and it is true unless you say otherwise, the compiler gets an extra remark plugin. The plugin ships in the package and its filter is four lines:

node_modules/next-mdx-remote/dist/plugins/remove-javascript-expressions.js
node.attributes = node.attributes.filter((attr) => {
  if (attr.type === 'mdxJsxAttribute') {
    // Keep literal values, remove expression values
    return (attr.value === null ||
      typeof attr.value === 'string' ||
      (attr.value && attr.value.type !== 'mdxJsxAttributeValueExpression'));
  }
  return attr.type !== 'mdxJsxExpressionAttribute';
});

A string value survives. An mdxJsxAttributeValueExpression does not, and neither does a spread. The same plugin removes mdxFlowExpression and mdxTextExpression nodes, which is why {1 + 1} in your body text renders as nothing rather than as 2.

This is deliberate. The package's own changelog for 6.0.0 says the options "control how JS in interpreted during compiling MDX" and that "Both default to true for security reasons" (PR #498). MDX from an untrusted source is executable code, and the block is a reasonable default for the remote-content case the library is named after. The cost is that everyone loading MDX from their own repository pays for a threat model they do not have.

This is not an RSC problem

Most write-ups of this pin it to React Server Components, and the search suggestion next-mdx-remote rsc exists because that is where people meet it. The attribution is wrong. Look at where the plugin is installed:

node_modules/next-mdx-remote/dist/serialize.js
function getCompileOptions(mdxOptions = {}, rsc = false, blockJS = true, blockDangerousJS = true) {
  const remarkPlugins = [
    ...(mdxOptions.remarkPlugins || []),
    ...(areImportsEnabled ? [] : [removeImportsExportsPlugin]),
    ...(blockJS ? [removeJavaScriptExpressions] : []),
    // …
  ];

rsc is a separate parameter and is used further down for providerImportSource only. The plugin list does not consult it. Compiling the same fixture both ways confirms it:

Terminal
node scripts/check-mdx-attrs.mjs --demo
# → width={1200} survives —  rsc: false   pages router: false

Both false. The pages-router entry point strips exactly what the RSC one does. This site's own CLAUDE.md recorded the behaviour as an RSC-mode quirk for three weeks; it was measured, and corrected in the same commit as this article.

Why next-mdx-remote-client exists

The mechanism above would be a footnote if there were somewhere to report it. There is not. As of 2026-08-11:

PackageRepositoryLatestnpm downloads, week of 2026-08-03
next-mdx-remotearchived, last push 2026-03-266.0.0, 2026-02-121,071,959
next-mdx-remote-clientactive, last push 2026-07-152.1.11442,870

The upstream GitHub repository is archived and read-only with 3,076 stars and 66 open issues that can no longer be answered. The npm package carries no deprecation flag, so npm install next-mdx-remote prints nothing at all — the usual signal that a dependency has stopped moving never fires. Version 6.0.0 shipped six weeks before the archive, which makes the expression block the library's permanent final behaviour.

next-mdx-remote-client is the fork, published by ipikuka, and its npm description states plainly that it "is a fork of next-mdx-remote". The fork's answer to the same fixture is the interesting part:

PackageProps the component receives
next-mdx-remote@6.0.0{"height":"630","title":"t"}
next-mdx-remote-client@2.1.11{"width":1200,"height":"630","priority":true,"title":"t"}

Note the types. width arrives as the number 1200 and priority as the boolean true, not as strings — the fork evaluates the expression rather than deleting it, so {1 + 1} in body copy renders <p>Sum: 2</p>. At 41% of upstream's weekly downloads, this is no longer a niche alternative.

The three fixes

Pick by where your MDX comes from, not by which is least work.

Write string attributes. width="1200", priority="true". This is what this site does, and it costs one rule in the authoring standard plus a coercion inside each component. It survives any future change to blockJS because it never relied on expressions at all. It is the right answer when you control the content, and it is the only answer that also works if you later accept MDX from anyone else.

Pass blockJS: false. One option, everything works, and blockDangerousJS remains on underneath it. That second guard is not nothing — it throws at compile time on a real list of identifiers:

Terminal
# with blockJS: false, blockDangerousJS still applies
{process.env.HOME}     # → Security: Access to 'process' properties is not allowed
{eval("1")}            # → Security: eval() calls are not allowed
{({}).constructor}     # → Security: .constructor access is not allowed
{[1,2].map(n => n * 2)} # → compiles

Useful, but it is a denylist walking an AST, not a sandbox. Turn blockJS off for content you author and commit. Leave it on for content anyone can submit.

Move to the fork. npm uninstall next-mdx-remote && npm install next-mdx-remote-client. The import paths and API differ — the RSC entry point exports evaluate() returning { content, error } rather than an MDXRemote component that throws — so this is a migration, not a swap. It buys a maintained dependency and an issue tracker that accepts issues, which over a multi-year site is worth more than the afternoon it costs.

The other errors v6 hands you

Three more come from the same compile path, and the messages point away from the cause in all three.

Could not parse expression with acorn. A literal { in prose. Writing set it to {"key": "value"} in the file without that code span asks MDX to parse an expression, and it fails the whole compile — which, in a static export, takes every other route with it. Escape it as \{ or wrap it in a code span.

This one is not hypothetical: the first build of this article failed on exactly that line, in the paragraph describing the error, because the example was written in italics rather than in backticks. next build reported Could not parse expression with acorn and exited before prerendering the remaining 41 pages.

Your imports silently disappear. removeImportsExportsPlugin is installed whenever useDynamicImport is falsy, which is the default. An import Foo from "./foo" at the top of your MDX is deleted, and the <Foo /> below it then fails as an undefined component. Setting mdxOptions.useDynamicImport: true restores it; passing the component through the components map instead is the better answer.

Expected component X to be defined. The compiled output calls _missingMdxReference for any capitalised tag not present in the components map. It is a render-time throw, not a compile error, so it escapes a serialize() that returned cleanly and surfaces as a 500 on one page.

Making the failure loud

A silent drop needs a check, because review will not catch it — the broken line looks exactly like the working one, and no debugger can be handed a message that was never produced. Seeded against ten of this project's real faults, this one was one of only two that printed nothing at all. scripts/check-mdx-attrs.mjs compiles the fixture above, prints the survival table, then scans every file in content/ for a braced attribute value on a capitalised tag and exits non-zero on a hit. Across 36 MDX files it currently reports none.

Getting there took two corrections worth repeating. The first run flagged <ArticleGrid posts={posts} /> — a TSX sample inside a fenced code block, in an article about writing components. A checker for JSX-in-MDX has to distinguish the document from the document's examples, or every article teaching this rule fails it; the scanner now blanks fenced blocks and inline code spans while preserving line numbers. The second is the rule about {/* … */} comments: those are mdxFlowExpression nodes and the plugin removes them too, which is exactly what a comment should do, so the scan deliberately ignores body expressions and looks only at attributes. That convention is the one this site relies on for deferred internal links, and it survives v6 by accident rather than by design.

Common mistakes

  • Assuming the prop name is wrong. The attribute is absent from the props object, which reads like a typo. Check whether the value was an expression first.
  • Trusting the absence of a deprecation warning. npm says nothing about next-mdx-remote. The repository being archived is the signal, and it is only visible on GitHub.
  • Filing it as an RSC bug. Both entry points strip it. The fix is the same either way.
  • Disabling blockJS on user-submitted MDX. blockDangerousJS is a denylist, not a sandbox. If strangers can write your MDX, keep the default and use strings.
  • Fixing one attribute and shipping. They fail as a class. Scan the whole corpus once.
  • Pinning to ^6.0.0 and moving on. The upstream repository cannot ship a 6.0.1. Whatever 6.0.0 does is what it will always do.

Conclusion

If you author your own MDX, write string attributes and add a scan that fails the build on a braced one — it costs an hour and it is immune to whatever the library does next. If you need real expressions, blockJS: false is one line and keeps a useful guard underneath. If you are starting a project today, install next-mdx-remote-client: the API differs enough to matter, but the alternative is a dependency whose issue tracker is closed and whose last release is its last. For the wider stack this sits inside, building a modern Next.js site with AI assistance covers the rest of the content pipeline, and the Next.js MCP server setup shows the same lib/ being reused outside the app.

Frequently asked questions

Why are my next-mdx-remote attributes not working?
Because next-mdx-remote 6.0.0 added a blockJS option that defaults to true. It installs a remark plugin that deletes every JSX attribute whose value is an expression, before compilation. width={1200} is not passed as undefined — the attribute is removed from the tree, so your component is called without it and nothing is logged. Write width="1200" instead, or pass blockJS: false.
What is next-mdx-remote-client?
A maintained fork of next-mdx-remote by ipikuka, published on npm as next-mdx-remote-client. It wraps @mdx-js/mdx for Next.js the same way, evaluates MDX expressions rather than stripping them, and preserves attribute types. In the week of 2026-08-03 it drew 442,870 npm downloads against next-mdx-remote's 1,071,959, roughly 41% of the original's volume.
Is next-mdx-remote deprecated?
The npm package is not marked deprecated, so installing it prints no warning. The GitHub repository is archived and read-only, with 66 open issues left unanswerable and its last push on 2026-03-26. Version 6.0.0 shipped on 2026-02-12, weeks before the archive, which means its behaviour change is permanent and will not be revised.
Does the expression drop only affect RSC mode?
No. The plugin is installed in getCompileOptions before the RSC flag is considered, so next-mdx-remote/serialize for the pages router strips the same attributes. We compiled the same source both ways and width={1200} was removed in each. Any documentation calling this an RSC quirk is describing where it was noticed, not what causes it.
Is blockJS: false safe to turn off?
It depends on where the MDX comes from. With blockJS: false, blockDangerousJS still applies and throws on eval, process, .constructor and similar — we confirmed "Security: Access to 'process' properties is not allowed". That is a real guard but not a sandbox. For MDX you author and commit, disabling the block is reasonable; for MDX submitted by users, keep the default and use string attributes.

Muhammad Kashif

Founder and editor of Devventa, covering AI coding assistants, Next.js and the modern AI development stack.