DEV Community

Cover image for Building a Markdown to HTML Converter with Live Preview in Next.js
Shaishav Patel
Shaishav Patel

Posted on • Originally published at ultimatetools.hashnode.dev

Building a Markdown to HTML Converter with Live Preview in Next.js

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]);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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}>`;
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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)