Skip to main content

How to Convert Markdown to JSX for Real React Components

Convert Markdown to JSX for React without an MDX toolchain. See how class becomes className, void tags self-close, and why this beats dangerouslySetInnerHTML.

Published By Li Lei
#markdown #jsx #react #converter #frontend

How to Convert Markdown to JSX for Real React Components

I keep my docs in Markdown because it's the fastest way to write prose with code in it. But my app is React, and a .md file is not a component. For a long time my answer was to reach for a runtime library, parse the Markdown in the browser on every render, and move on. That works until you notice the parser sitting in your bundle, re-chewing the same fixed text on every page load. When the content never changes per request — a docs page, a changelog block, an LLM answer you want to pin into a help panel — converting Markdown to JSX once is the better trade. This post walks through what that conversion actually involves, where the sharp edges are, and why I stopped piping static Markdown through dangerouslySetInnerHTML.

Markdown to HTML is the easy half

The conversion happens in two stages, and it helps to keep them separate in your head. First, the Markdown is parsed to HTML: # Title becomes <h1>Title</h1>, a bullet list becomes <ul><li>, a numbered list becomes <ol>, a fenced code block becomes <pre><code>, and inline **bold**, *italic*, ` code `, links, and images become their tag equivalents. This part is well-understood — it's the same job any Markdown renderer does. If you only need HTML and stop here, the Markdown to HTML tool does exactly that stage.

The interesting half is the second one. HTML is not JSX. JSX is JavaScript that happens to look like markup, and the differences are small but real enough to make the compiler reject your paste if you ignore them.

Why HTML can't just be dropped into a React component

There are three concrete gaps between valid HTML and valid JSX, and the converter closes all three.

class becomes className. In JSX, class is a reserved word in JavaScript, so React uses the className prop instead. A pasted <div class="card"> won't error loudly — it just silently fails to apply the class. The converter renames every class attribute to className, and the related for attribute on labels to htmlFor.

Void elements must self-close. HTML is forgiving about <img src="x.png"> and <br> with no closing tag. JSX is not — every element must be explicitly closed, so these become <img src="x.png" /> and <br />. Forget the slash and the parser keeps reading, swallowing whatever follows as the tag's children.

Code-block language hints survive as a className. A fenced block written as ` `js lands as <pre><code className="language-js">`, so a syntax highlighter like Prism, Shiki, or highlight.js recognizes it without any extra wiring.

There's one subtlety worth flagging up front: inside a code block, the curly braces { and } are escaped to JSX-safe entities. JSX reads bare braces as the start of an expression, so leaving them raw inside a code sample would break the build. If you wanted literal braces displayed, that's correct; if you actually meant a JSX expression, you'll unescape it by hand.

A worked example

Here's a small Markdown input:

## Install

Run the command below, then open <http://localhost:3000>:

npm install --watch


- Works offline
- No API key needed

Convert it with "Wrap as component" turned on, and you get JSX that compiles on the first try:

export default function Content() {
  return (
    <>
      <h2>Install</h2>
      <p>
        Run the command below, then open{" "}
        <a href="http://localhost:3000">http://localhost:3000</a>:
      </p>
      <pre>
        <code className="language-bash">npm install --watch</code>
      </pre>
      <ul>
        <li>Works offline</li>
        <li>No API key needed</li>
      </ul>
    </>
  );
}

Notice three things. The bash hint became className="language-bash". The whole thing is wrapped in a Fragment <>…</> rather than a <div>, because a React return needs a single root and Markdown almost always produces several top-level blocks — a heading, a paragraph, a code block, a list. And the component is a real, named function you can rename, style, and feed props into. That last point is the whole reason to bother.

The case against dangerouslySetInnerHTML

The shortcut a lot of people reach for is to render Markdown to an HTML string and inject it:

<div dangerouslySetInnerHTML={{ __html: renderedHtml }} />

It works, and for genuinely user-generated content that you've sanitized, it's sometimes the pragmatic choice. But for static content it's the wrong tool, and the name is a fair warning. The injected markup is an opaque string, not React elements — you can't target a heading to add an id, you can't swap a hardcoded value for a prop, you can't split a section into its own component, and React can't reconcile anything inside that subtree. Styling means writing CSS that reaches into a blob you don't really control. And if the HTML came from anywhere untrusted, that prop is a direct XSS vector; the word "dangerously" is doing real work.

Converting to JSX gives you the opposite of all that. The output is reviewable in a pull request, the elements are first-class React nodes you can style and restructure, and there is no __html string for an attacker to smuggle a <script> through. For fixed content, you get the rendered result without the runtime parser and without the injection risk.

When to convert, and when not to

The rule I follow: convert at build time when the Markdown is fixed, render at runtime when it isn't. A docs page, release notes, a marketing block, an assistant's answer you want to freeze into a static panel — all fixed, all good candidates. Convert once, drop the JSX into a .tsx file, and you ship zero parser and pay zero per-render parse cost.

Keep a runtime renderer for the genuinely dynamic cases: a comment box, a CMS field an editor changes daily, anything arriving over the network per request. And if your source is already HTML rather than Markdown, skip the first stage entirely and use the HTML to JSX tool, which is the dedicated second half of this same pipeline. Going the other direction — cleaning scraped HTML back into Markdown — is what HTML to Markdown is for.

A few practical notes from using this daily. If you paste raw HTML into a Markdown converter expecting Markdown rules to kick in, they won't — a literal <div class="x"> is treated as inline HTML and passed through untouched. And if you turn off the component wrapper but still paste several blocks, remember to leave "Wrap in Fragment" on, or the multi-block output won't satisfy JSX's single-root requirement.

Try it on your own README

The fastest way to see the difference is to grab a "Getting started" section from a README you already maintain and run it through the Markdown to JSX converter. Tick "Wrap as component," pick your indentation, and copy the result straight into your codebase. You'll skip the line-by-line class-to-className cleanup and the wrap-everything-in-a-div busywork that usually eats the first ten minutes of moving docs into React.


Made by Toolora · Updated 2026-06-13