Build a Live Markdown Editor
Learn how to build a split-pane markdown editor with real-time preview. You'll write your own markdown-to-HTML parser from scratch using regex and string manipulation — no libraries allowed. This question tests parsing logic, performance awareness, and layout skills.
Table of Contents
What is a Markdown Editor?
A live markdown editor is a split-pane interface where the user writes raw markdown on one side and sees the rendered HTML preview on the other — updating in real time as they type. It's the same pattern used by GitHub's comment boxes, Notion, and countless documentation tools.
Documentation
README files, wikis, and docs sites all use markdown. Editors with live preview make authoring fast and error-free.
CMS & Blogging
Many headless CMS platforms use markdown for content. A live editor lets authors see formatting without publishing.
Developer Tools
Issue trackers, PR descriptions, and code comments all support markdown. Inline preview reduces context switching.
Why interviewers ask this
This question tests multiple skills at once: string parsing with regex, understanding of HTML rendering, performance optimization (re-parsing on every keystroke), and layout skills (responsive split pane). It's a great signal for mid-to-senior frontend roles.
Architecture & Layout
The component is straightforward: a textarea on the left, a preview div on the right, and a parser function that converts the markdown string to HTML on every change.
User types in the textarea
The onChange handler updates the markdown state with the new value. The input stays responsive because we're only updating a string.
Parser converts markdown to HTML
A pure function takes the markdown string and returns an HTML string. This runs on every state change — useMemo prevents unnecessary re-parses when other state changes.
Preview renders the HTML
The HTML string is rendered using dangerouslySetInnerHTML on a div. The preview updates instantly as the user types.
"use client"; import { useState, useMemo } from "react"; function parseMarkdown(md: string): string { // Your parser here — returns HTML string return md; } export default function MarkdownEditor() { const [markdown, setMarkdown] = useState("# Hello World"); // Only re-parse when markdown changes const html = useMemo(() => parseMarkdown(markdown), [markdown]); return ( <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}> <textarea value={markdown} onChange={(e) => setMarkdown(e.target.value)} style={{ height: 500, fontFamily: "monospace" }} /> <div dangerouslySetInnerHTML={{ __html: html }} /> </div> ); }
Responsive layout
Use CSS Grid with grid-template-columns: 1fr 1fr for the split pane. On mobile, switch to a single column with a media query or Tailwind's md:grid-cols-2.
Parsing Strategy
Markdown parsing happens in two phases: block-level parsing (headings, lists, code blocks, blockquotes) and inline parsing (bold, italic, links, code). Block-level runs first on full lines, then inline runs on the text content within each block.
Input: "## Hello **world**" Phase 1 — Block parsing: Detects "##" → wraps in <h2> Result: <h2>Hello **world**</h2> Phase 2 — Inline parsing: Detects "**world**" → wraps in <strong> Result: <h2>Hello <strong>world</strong></h2>
The order of operations matters. Code blocks must be parsed first (before any inline rules), because content inside code blocks should not be transformed. Similarly, images must be parsed before links (since image syntax  contains link syntax [text](url)).
| Order | Element | Why this order |
|---|---|---|
| 1 | Fenced code blocks | Content inside must not be parsed by other rules |
| 2 | Headings, HR, blockquotes | Line-level patterns — easy to detect by prefix |
| 3 | Lists (ul / ol) | Consecutive lines need to be grouped into one element |
| 4 | Paragraphs | Catch-all for remaining non-empty lines |
| 5 | Inline (bold, italic, links, code) | Runs inside each block's text content |
Block-Level Parsing
Block-level parsing works line by line. Split the input by newlines, iterate through, and match each line against block patterns. Some blocks (lists, blockquotes) span multiple consecutive lines and need to be grouped.
Fenced Code Blocks
These must be handled first, before splitting into lines, because they can contain characters that look like other markdown syntax.
// Run BEFORE line-by-line parsing // Match ``` ... ``` blocks and wrap in <pre><code> html = html.replace( /```(\w*)\n([\s\S]*?)```/g, (_match, _lang, code) => `<pre><code>${escapeHtml(code.trimEnd())}</code></pre>` ); function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">"); }
Why escapeHtml?
Code blocks can contain HTML-like content (e.g., <div>). Without escaping, the browser would interpret it as real HTML. Always escape content inside code blocks.
Headings
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { const level = headingMatch[1].length; // 1-6 const text = headingMatch[2]; output.push(`<h${level}>${parseInline(text)}</h${level}>`); }
Lists (grouping consecutive lines)
// Unordered list: lines starting with "- " or "* " if (/^[-*]\s+/.test(line)) { const items: string[] = []; while (i < lines.length && /^[-*]\s+/.test(lines[i])) { items.push(lines[i].replace(/^[-*]\s+/, "")); i++; } output.push( `<ul>${items.map(item => `<li>${parseInline(item)}</li>`).join("")}</ul>` ); continue; // skip i++ at end of loop } // Ordered list: lines starting with "1. ", "2. ", etc. if (/^\d+\.\s+/.test(line)) { const items: string[] = []; while (i < lines.length && /^\d+\.\s+/.test(lines[i])) { items.push(lines[i].replace(/^\d+\.\s+/, "")); i++; } output.push( `<ol>${items.map(item => `<li>${parseInline(item)}</li>`).join("")}</ol>` ); continue; }
The while loop pattern
Lists and blockquotes are "greedy" blocks — they consume consecutive matching lines. Use a while loop inside your main for loop to collect all items, then emit a single <ul> or <ol> wrapping all of them.
Inline Parsing
Inline parsing transforms text within a block element. It handles bold, italic, links, images, and inline code. The order matters — process images before links, bold before italic, and inline code early to prevent conflicts.
function parseInline(text: string): string { let result = text; // Images  — BEFORE links result = result.replace( /!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%" />' ); // Links [text](url) result = result.replace( /\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>' ); // Bold **text** or __text__ result = result.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>"); result = result.replace(/__(.+?)__/g, "<strong>$1</strong>"); // Italic *text* or _text_ result = result.replace(/\*(.+?)\*/g, "<em>$1</em>"); result = result.replace(/_(.+?)_/g, "<em>$1</em>"); // Inline code \`code\` result = result.replace(/\`([^\`]+)\`/g, "<code>$1</code>"); return result; }
| Syntax | Regex | Output |
|---|---|---|
| **bold** | /\*\*(.+?)\*\*/g | <strong>bold</strong> |
| *italic* | /\*(.+?)\*/g | <em>italic</em> |
| `code` | /`([^`]+)`/g | <code>code</code> |
| [text](url) | /\[([^\]]+)\]\(([^)]+)\)/g | <a href="url">text</a> |
Bold before italic
If you parse italic (*text*) before bold (**text**), the bold markers get partially consumed by the italic regex. Always process the more specific pattern (double asterisk) first.
Rendering with dangerouslySetInnerHTML
React doesn't render raw HTML strings by default — you need dangerouslySetInnerHTML. The name is intentionally scary because injecting HTML can open XSS vulnerabilities if the input isn't trusted.
const html = useMemo(() => parseMarkdown(markdown), [markdown]); <div className="preview-pane" dangerouslySetInnerHTML={{ __html: html }} />
Why useMemo?
Parsing runs on every keystroke. If the component has other state (e.g., a toolbar toggle), you don't want to re-parse the markdown when unrelated state changes. useMemo ensures the parser only runs when the markdown string actually changes.
XSS Considerations
In an interview context, the input is from the user themselves (not from a database or another user), so XSS risk is low. But always mention it proactively:
// In production, sanitize the HTML output before rendering: // - Use a library like DOMPurify // - Or build a whitelist of allowed tags/attributes // - Never render user-generated HTML from a database without sanitization import DOMPurify from "dompurify"; // production only const safeHtml = DOMPurify.sanitize(parseMarkdown(markdown));
Interview tip
Mentioning XSS and sanitization unprompted shows security awareness. Say: "In production I'd sanitize with DOMPurify, but for this exercise the input is self-authored so it's safe."
Edge Cases & Enhancements
The basic parser handles the happy path. Here are the edge cases and enhancements interviewers will ask about:
| Edge Case | Solution |
|---|---|
| Nested bold/italic | ***bold italic*** — parse bold first, then italic runs on the inner text |
| HTML inside markdown | Escape angle brackets in regular text, but allow them in code blocks (already escaped by escapeHtml) |
| Nested lists | Detect indentation level (2 or 4 spaces) and create nested ul/ol elements. This adds significant complexity |
| Multi-line paragraphs | Consecutive non-empty lines without a block prefix should be joined into a single paragraph |
| Performance on large docs | useMemo prevents re-parsing on unrelated renders. For very large docs, debounce the parsing or use a web worker |
| Synchronized scrolling | Match scroll position between editor and preview using scroll percentage: scrollTop / scrollHeight |
const editorRef = useRef<HTMLTextAreaElement>(null); const previewRef = useRef<HTMLDivElement>(null); const handleEditorScroll = () => { const editor = editorRef.current; const preview = previewRef.current; if (!editor || !preview) return; const scrollPercent = editor.scrollTop / (editor.scrollHeight - editor.clientHeight); preview.scrollTop = scrollPercent * (preview.scrollHeight - preview.clientHeight); }; <textarea ref={editorRef} onScroll={handleEditorScroll} ... /> <div ref={previewRef} ... />
Scope your solution
In an interview, implement the basic parser first (headings, bold, italic, links, lists, code). Then mention enhancements like nested lists, sync scroll, and toolbar buttons as "next steps." Don't over-engineer the first pass.
Common Interview Follow-up Questions
After building the editor, expect these follow-ups:
Q:Why use dangerouslySetInnerHTML instead of building a React element tree?
A: dangerouslySetInnerHTML is simpler and faster for this use case — you're converting a string to a string. Building a React element tree (createElement calls) would require a full AST parser, which is significantly more complex. Mention that in production you'd sanitize the HTML with DOMPurify.
Q:How would you handle XSS if the markdown comes from another user?
A: Never render untrusted HTML directly. Use a sanitization library like DOMPurify to strip dangerous tags (script, iframe, event handlers). Whitelist only safe tags and attributes. Alternatively, build a React element tree from an AST so you never use dangerouslySetInnerHTML.
Q:How would you optimize for very large documents?
A: Three approaches: (1) Debounce the parsing — don't re-parse on every keystroke, wait 100-200ms. (2) Move parsing to a Web Worker so it doesn't block the main thread. (3) Only re-parse the changed section (incremental parsing), though this is complex to implement.
Q:Why parse bold before italic?
A: Bold uses ** (double asterisk) and italic uses * (single). If italic runs first, it matches the inner asterisks of ** and breaks the bold pattern. Always process the more specific (longer) pattern first.
Q:How would you add a toolbar with formatting buttons?
A: Track the textarea's selectionStart and selectionEnd. When the user clicks Bold, wrap the selected text with **. Use document.execCommand or manual string slicing: text.slice(0, start) + '**' + text.slice(start, end) + '**' + text.slice(end). Update state and restore cursor position.
Q:How does this compare to using a library like marked or remark?
A: Libraries handle the full CommonMark spec (600+ edge cases), support plugins, and are battle-tested. A hand-rolled parser covers the 80% case and is great for interviews, but you'd never ship it to production. Mention this tradeoff to show pragmatism.
Q:How would you add syntax highlighting to code blocks?
A: Use a library like Prism.js or highlight.js. After parsing the code block, pass the content and language to the highlighter, which returns HTML with span elements and CSS classes for tokens. This is a post-processing step after your parser runs.
Ready to build it yourself?
We've set up a split-pane editor with a textarea and preview div. Write your own markdown parser and wire it up for real-time rendering.
Built for developers, by developers. Happy coding! 🚀