A Markdown editor is essential for content management systems and documentation tools. Here is how to build one.
Step 1: Install Dependencies
pnpm add react-markdown remark-gfm rehype-highlight rehype-sanitize
Step 2: Basic Split-Pane Editor
"use client";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import rehypeSanitize from "rehype-sanitize";
const defaultContent = `# Hello World
This is a **Markdown** editor with _live preview_.
## Features
- Split-pane editing
- Live preview
- Syntax highlighting
- GitHub Flavored Markdown
\`\`\`javascript
const greeting = "Hello, World!";
console.log(greeting);
\`\`\`
> This is a blockquote.
| Header 1 | Header 2 |
|----------|----------|
| Cell 1 | Cell 2 |
`;
export function MarkdownEditor() {
const [content, setContent] = useState(defaultContent);
const [view, setView] = useState<"split" | "edit" | "preview">("split");
return (
<div className="flex h-[600px] flex-col rounded-xl border dark:border-gray-700">
{/* Toolbar */}
<div className="flex items-center justify-between border-b px-3 py-2 dark:border-gray-700">
<EditorToolbar
onInsert={(text) => setContent((prev) => prev + text)}
/>
<div className="flex gap-1">
{(["edit", "split", "preview"] as const).map((v) => (
<button
key={v}
onClick={() => setView(v)}
className={`rounded px-2 py-1 text-xs capitalize ${
view === v
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
: "text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
}`}
>
{v}
</button>
))}
</div>
</div>
{/* Editor / Preview */}
<div className="flex flex-1 overflow-hidden">
{view !== "preview" && (
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="flex-1 resize-none border-r p-4 font-mono text-sm focus:outline-none dark:border-gray-700 dark:bg-gray-900"
placeholder="Write your Markdown here..."
spellCheck={false}
/>
)}
{view !== "edit" && (
<div className="flex-1 overflow-y-auto p-4">
<div className="prose dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeSanitize]}
>
{content}
</ReactMarkdown>
</div>
</div>
)}
</div>
{/* Status bar */}
<div className="flex items-center justify-between border-t px-3 py-1 text-xs text-gray-500 dark:border-gray-700">
<span>Markdown</span>
<span>
{content.split("\n").length} lines | {content.length} characters
</span>
</div>
</div>
);
}
Step 3: Formatting Toolbar
"use client";
import {
Bold,
Italic,
Heading1,
Heading2,
List,
ListOrdered,
Code,
Link,
Image,
Quote,
Minus,
} from "lucide-react";
interface ToolbarProps {
onInsert: (text: string) => void;
}
const tools = [
{ icon: Bold, label: "Bold", insert: "**bold text**" },
{ icon: Italic, label: "Italic", insert: "_italic text_" },
{ icon: Heading1, label: "Heading 1", insert: "\n# " },
{ icon: Heading2, label: "Heading 2", insert: "\n## " },
{ icon: List, label: "Bullet List", insert: "\n- Item 1\n- Item 2\n- Item 3\n" },
{ icon: ListOrdered, label: "Numbered List", insert: "\n1. Item 1\n2. Item 2\n3. Item 3\n" },
{ icon: Code, label: "Code Block", insert: "\n```\ncode here\n```\n" },
{ icon: Quote, label: "Quote", insert: "\n> " },
{ icon: Link, label: "Link", insert: "[link text](https://example.com)" },
{ icon: Image, label: "Image", insert: "" },
{ icon: Minus, label: "Divider", insert: "\n---\n" },
];
export function EditorToolbar({ onInsert }: ToolbarProps) {
return (
<div className="flex gap-0.5">
{tools.map(({ icon: Icon, label, insert }) => (
<button
key={label}
onClick={() => onInsert(insert)}
title={label}
className="rounded p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<Icon className="h-4 w-4" />
</button>
))}
</div>
);
}
Step 4: Selection-Aware Formatting
"use client";
import { useRef, useCallback } from "react";
export function useEditorFormatting() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const wrapSelection = useCallback((before: string, after: string) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = textarea.value.substring(start, end);
const replacement = `${before}${selected || "text"}${after}`;
textarea.setRangeText(replacement, start, end, "select");
textarea.focus();
// Trigger React onChange
const event = new Event("input", { bubbles: true });
textarea.dispatchEvent(event);
}, []);
const insertAtCursor = useCallback((text: string) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
textarea.setRangeText(text, start, start, "end");
textarea.focus();
const event = new Event("input", { bubbles: true });
textarea.dispatchEvent(event);
}, []);
return {
textareaRef,
bold: () => wrapSelection("**", "**"),
italic: () => wrapSelection("_", "_"),
code: () => wrapSelection("`", "`"),
link: () => wrapSelection("[", "](url)"),
heading: (level: number) =>
insertAtCursor("\n" + "#".repeat(level) + " "),
insertAtCursor,
};
}
Step 5: Export Options
export function EditorExport({
content,
filename = "document",
}: {
content: string;
filename?: string;
}) {
function downloadMarkdown() {
const blob = new Blob([content], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${filename}.md`;
a.click();
URL.revokeObjectURL(url);
}
function downloadHTML() {
// Simple markdown to HTML (use a proper converter in production)
const html = `<!DOCTYPE html>
<html>
<head><title>${filename}</title></head>
<body>${content}</body>
</html>`;
const blob = new Blob([html], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${filename}.html`;
a.click();
URL.revokeObjectURL(url);
}
function copyToClipboard() {
navigator.clipboard.writeText(content);
}
return (
<div className="flex gap-2">
<button
onClick={downloadMarkdown}
className="rounded border px-3 py-1 text-xs hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-800"
>
Download .md
</button>
<button
onClick={downloadHTML}
className="rounded border px-3 py-1 text-xs hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-800"
>
Download .html
</button>
<button
onClick={copyToClipboard}
className="rounded border px-3 py-1 text-xs hover:bg-gray-100 dark:border-gray-700 dark:hover:bg-gray-800"
>
Copy
</button>
</div>
);
}
Need a Content Management System?
We build CMS solutions with rich editing, markdown support, and content workflows. Contact us to discuss your project.