We built a browser-based Markdown to HTML converter for Ultimate Tools that updates in real time as you type. Here's the full implementation using marked.js, React state, and a downloadable HTML export with embedded CSS.
Why Build This?
Every developer works with Markdown. READMEs, documentation, blog drafts, changelogs. But when you need the HTML output — for a CMS, an email template, or a quick landing page — you either fire up VS Code, install Pandoc, or use an online tool that sends your content to a server.
We wanted something simpler: paste Markdown, see HTML, copy or download. Zero friction, zero uploads.
The Stack
- marked.js — Industry-standard Markdown parser. Fast, spec-compliant, 35KB.
-
React (
useState,useEffect) — State management and reactive updates. - Next.js App Router — Server component for SEO page, client component for the converter.
- Tailwind CSS — Styling and dark mode support.
Core Conversion Logic
The heart of the converter is three lines of code:
import { marked } from "marked";
const [markdown, setMarkdown] = useState(sampleMarkdown);
const [html, setHtml] = useState("");
useEffect(() => {
const result = marked(markdown, { async: false }) as string;
setHtml(result);
}, [markdown]);
Every time markdown state changes (on every keystroke), useEffect fires and runs marked() synchronously. The result is stored in html state, which drives both the preview and raw HTML views.
Why async: false?
marked.js supports both sync and async modes. Async mode is useful when you have custom async extensions (e.g., fetching remote content during parsing). We don't need that — our conversion is pure text transformation — so sync mode eliminates unnecessary Promise overhead and keeps the update cycle tight.
The Dual-Panel Layout
The UI is a two-column grid: Markdown input on the left, output on the right.
<div className="grid gap-6 lg:grid-cols-2">
{/* Left: Markdown Editor */}
<div>
<textarea
value={markdown}
onChange={(e) => setMarkdown(e.target.value)}
className="w-full h-[520px] font-mono text-sm ..."
placeholder="Type or paste Markdown here..."
/>
</div>
{/* Right: Output Panel */}
<div>
{/* Tab toggle: Preview | HTML */}
{tab === "preview" ? (
<div
className="prose dark:prose-invert ..."
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
<pre><code>{html}</code></pre>
)}
</div>
</div>
The Preview Tab
Uses dangerouslySetInnerHTML to render the converted HTML. The prose class (from Tailwind Typography, or custom styles) handles heading sizes, paragraph spacing, list indentation, and code block styling.
Security note: Since this is a client-side tool where users convert their own content, the XSS risk from dangerouslySetInnerHTML is acceptable. In a multi-user app (comments, forums), you'd want to sanitize with DOMPurify first.
The HTML Tab
Shows the raw HTML string in a <pre><code> block. This is what users copy for their CMS, email templates, or static pages.
The Copy Button
const [copied, setCopied] = useState(false);
const copyHtml = async () => {
await navigator.clipboard.writeText(html);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
Standard clipboard pattern: write to clipboard, show "Copied!" feedback for 2 seconds, reset. The button icon switches from Copy to Check (Lucide React icons) during the feedback window.
The Download Function
We don't just dump the raw HTML fragment — we wrap it in a complete, standalone HTML document with embedded CSS:
const downloadHtml = () => {
const fullHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown Export</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.7;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
color: #1a1a1a;
}
h1, h2, h3 { margin-top: 1.5em; }
pre {
background: #f4f4f5;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
}
code {
background: #f4f4f5;
padding: 0.15rem 0.3rem;
border-radius: 0.25rem;
font-size: 0.9em;
}
pre code { background: none; padding: 0; }
blockquote {
border-left: 4px solid #6366f1;
margin: 1rem 0;
padding: 0.5rem 1rem;
color: #555;
}
hr { border: none; border-top: 1px solid #e4e4e7; margin: 2rem 0; }
</style>
</head>
<body>
${html}
</body>
</html>`;
const blob = new Blob([fullHtml], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "markdown-export.html";
a.click();
URL.revokeObjectURL(url);
};
Why embed the CSS?
Because users want a file they can open in a browser and have it look good immediately. Without CSS, the raw HTML renders with browser defaults — no max-width container, no readable line lengths, ugly code blocks.
Stats Display
Below the editor, we show three metrics:
<div className="flex gap-4 text-xs text-zinc-500">
<span>{markdown.length} characters</span>
<span>{markdown.split("\n").length} lines</span>
<span>{new Blob([html]).size} bytes HTML</span>
</div>
The HTML size uses Blob to get the accurate byte count — important for users checking if their output fits within a CMS character limit.
marked.js Configuration
We use marked() with default settings, which means:
- GitHub Flavored Markdown (GFM) — Tables, strikethrough, task lists
- Smart quotes — Off by default (raw quotes preserved)
- No sanitization — marked.js removed built-in sanitization in v5. For user-facing apps, pair with DOMPurify.
For this tool, defaults work perfectly. If you need custom behavior, marked supports extensions:
// Example: custom heading renderer with anchor IDs
marked.use({
renderer: {
heading({ tokens, depth }) {
const text = this.parser.parseInline(tokens);
const slug = text.toLowerCase().replace(/\s+/g, '-');
return `<h${depth} id="${slug}">${text}</h${depth}>`;
}
}
});
Performance
On a modern browser, marked() converts a 10,000-word document in under 5ms. Running it on every keystroke with useEffect is fine — there's no perceptible delay even on long documents.
If you were building an editor for very long documents (50,000+ words), you'd want to debounce:
useEffect(() => {
const timeout = setTimeout(() => {
setHtml(marked(markdown, { async: false }) as string);
}, 150);
return () => clearTimeout(timeout);
}, [markdown]);
We didn't need this — our use case is quick conversions, not long-form editing.
Try It
The tool is live at Markdown to HTML Converter.
It's part of Ultimate Tools — a free, privacy-first collection of 24+ browser-based utilities for PDFs, images, QR codes, and developer tools. All processing happens client-side.
The full converter is under 100 lines of logic. marked.js does the heavy lifting — our job was wrapping it in a good UX with live preview, dual output modes, and a download that actually looks professional when opened.
Top comments (0)