Markdown editor with live preview
A two-pane React Markdown editor whose tiny escape-first renderer is safe against HTML injection and needs no packages.
React / Next.jsFormsmarkdowneditorreactpreview
Live preview
Code
'use client';
import { useMemo, useState } from 'react';
const escapeHtml = (s: string) =>
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
function inline(s: string) {
return s
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
// only http(s) links, so javascript: URLs can never be injected
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" rel="noopener noreferrer" target="_blank">$1</a>');
}
export function render(md: string): string {
const blocks: string[] = [];
// Lift fenced code out first so inline rules never touch it.
const text = escapeHtml(md).replace(/```\n?([\s\S]*?)```/g, (_, code) => {
blocks.push(`<pre><code>${code}</code></pre>`);
return `\u0000${blocks.length - 1}\u0000`;
});
const out: string[] = [];
let list = false;
for (const line of text.split('\n')) {
const item = /^[-*]\s+(.*)/.exec(line);
if (item) {
if (!list) out.push('<ul>');
list = true;
out.push(`<li>${inline(item[1]!)}</li>`);
continue;
}
if (list) { out.push('</ul>'); list = false; }
const h = /^(#{1,3})\s+(.*)/.exec(line);
if (h) out.push(`<h${h[1]!.length}>${inline(h[2]!)}</h${h[1]!.length}>`);
else if (line.trim()) out.push(`<p>${inline(line)}</p>`);
}
if (list) out.push('</ul>');
return out.join('\n').replace(/\u0000(\d+)\u0000/g, (_, i) => blocks[Number(i)]!);
}
export function MarkdownEditor({ initial = '' }: { initial?: string }) {
const [text, setText] = useState(initial);
const html = useMemo(() => render(text), [text]);
return (
<div className="grid gap-4 md:grid-cols-2">
<textarea
aria-label="Markdown source"
value={text}
onChange={(e) => setText(e.target.value)}
className="min-h-64 rounded border p-3 font-mono text-sm"
/>
<div className="prose max-w-none rounded border p-3" dangerouslySetInnerHTML={{ __html: html }} />
</div>
);
}
How it works
What it solves
Many forms need a small Markdown box for comments, notes or descriptions. A full parser is heavy, and rendering user Markdown as HTML is dangerous if sanitising is done carelessly. This component handles the common syntax with a renderer built around one safety rule: escape first.
How it works
render replaces &, <, > and quotes in the whole input before it looks for any Markdown syntax. After that, the text contains no real tags, so the later replacements can only introduce the specific elements they create. Fenced code blocks are lifted out first and replaced by placeholders so that their content is not touched by the inline rules, then restored.
Block rules process line by line: headings from # to ###, bullet lists collected into one <ul>, and paragraphs for everything else. Inline rules then handle bold, italic, inline code and links. The link rule only accepts URLs starting with http:// or https://, so javascript: URLs cannot be injected, and it adds rel="noopener noreferrer".
The component keeps the text in state, renders the output with useMemo, and shows it in a second pane via dangerouslySetInnerHTML, which is acceptable only because of the escape-first design.
Adapting it
- Swap in a full parser plus a sanitiser when you need tables or nested lists.
- Add toolbar buttons that wrap the selection with markers.
- Save drafts with a localStorage hook.
Pitfalls
- Regex Markdown handles simple cases only; nested emphasis can misbehave.
- Never skip the escape step when extending the rules.
How to use it
- Save as
components/MarkdownEditor.tsx(client component). - No dependencies; Tailwind classes are cosmetic.
- Render
<MarkdownEditor initial="# Hello" />. - Import
renderseparately if you only need the HTML string on the server.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.