A JSX expression-container attribute silently loses its value in next-mdx-remote

TIL that generating attr={"..."} into MDX source instead of attr="..." made a prop come back as an empty object on every render, with no error anywhere in the pipeline — found while writing the Obsidian-callout preprocessor for this site.

3 min read

Sabin Shrestha

Full-Stack Developer — Next.js, React & React Native

The Problem #

This site's TIL and notes collections are written as plain Obsidian vaults — > [!tip] callouts, wikilinks, ```ts title="foo.ts" fences — so the source stays readable and editable in Obsidian itself. A preprocessing pass (src/lib/mdx-preprocess.ts) rewrites that syntax into JSX (<Callout>, <CodeBlockWithFilename>) before next-mdx-remote compiles it. While building the callout transform, the rendered title on every callout came back blank.

Context #

preprocessObsidianCallouts turns a line like > [!tip] Watch out into a generated string: `<Callout type="${type}" title="${title}">`, which gets spliced into the Markdown source and compiled by MDXRemote from next-mdx-remote/rsc on the server (src/components/content/ContentBody.tsx).

What I Tried #

The first version generated the title attribute as a JS expression container, the way you'd write it in hand-authored JSX when a value needs escaping: `<Callout title={"${escapeJsxAttr(title)}"}>`. That's normal, idiomatic JSX.

What Went Wrong #

Callout's title prop rendered as empty every time, for every callout type, with correct data reaching the string right up until it hit the compiled output. No compile error, no runtime warning — title was just undefined inside the component. Logging inside Callout showed props arriving as {} for that attribute, as if the attribute had never been written into the source at all.

The Solution #

Generate the attribute as a plain double-quoted JSX attribute instead of an expression container, escaping it like an HTML attribute rather than a JS string literal:

function escapeJsxAttr(value: string): string {
  return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
 
out.push(`<Callout type="${type}" title="${escapeJsxAttr(title)}">`, ...);

attr="..." survives the pipeline; attr={"..."} doesn't — confirmed directly against this project's compiled next-mdx-remote output on Next 16 with Turbopack.

Why It Works #

title="text" is parsed as a JSX string attribute — the simplest, most literal form MDX's parser has to handle. title={"text"} is parsed as a JSX expression container holding a JS expression, which routes through a different part of the MDX-to-JS compilation than a plain string attribute does. Somewhere in that path, for this MDX pipeline's specific compiler/bundler combination, the expression container's value doesn't make it into the final props object — it compiles clean and runs without throwing, it just discards the value. Since the code is generating the JSX text itself rather than a developer hand-typing it, there's no reason to use the expression-container form at all: a plain quoted attribute says exactly the same thing and isn't affected.

Lessons Learned #

When generating JSX as a string for a compile pipeline (rather than writing it by hand in a .tsx file), don't assume every syntactically valid JSX form behaves identically once it goes through that compiler. A form that's perfectly normal in hand-written JSX can behave differently — silently — when it's the output of string interpolation feeding an MDX toolchain. "No error" is not the same as "correct" when the bug is a value quietly dropped rather than a compile failure.

What I Would Do Differently #

I'd default to the plain-attribute form from the start for any generated JSX, and treat expression containers as something to reach for only when a real non-string value (a number, boolean, or array) actually requires one — which none of this preprocessor's generated attributes do.

MDX compilation internals, remark/rehype AST transforms vs. string preprocessing, debugging "no error but wrong output" bugs in a compile pipeline.