XSS errors when rendering Markdown are among the most common vulnerabilities on sites with user-generated content. For example, an attacker enters [clickme](javascript:alert(1)), and if the parser does not sanitize, JavaScript executes in another user's browser. Over 5 years, we have delivered over 20 Markdown editor projects with live preview and GFM, and we know how to avoid typical mistakes. At the start, we audit requirements and select the optimal solution, which saves up to 30% of debugging time and, in monetary terms, up to 40% of the budget.
Problems We Solve
-
XSS via Markdown: Standard marked.js does not escape by default; DOMPurify is needed. Server-side sanitization (CommonMark with
html_input=strip) eliminates 99% of risks. Without it, you risk data loss and reputation damage. -
Hydration mismatch with SSR: React re-renders the preview on the client if the HTML does not match the server — we fix it using
suppressHydrationWarningor disable SSR for the editor. This reduces deployment time by 2-3 days. - Live-preview performance: Each character input triggers HTML parsing — we buffer with a 100ms debounce and use virtualization. This reduces CPU load by 60% and improves INP by 40%.
How We Do It
We select the library based on the task. We use @uiw/react-md-editor for typical projects, and a custom editor on CodeMirror for high-load systems. CodeMirror 6 with marked.js is 40% lighter in bundle size (gzip ~30KB vs ~40KB for @uiw/react-md-editor) but requires twice as much integration code. Comparison table:
| Library | Live Preview | GFM | Image Upload | SSR | Weight (gzip) |
|---|---|---|---|---|---|
| @uiw/react-md-editor | Yes | Yes | No (custom) | Yes | ~40KB |
| CodeMirror 6 + marked.js | Yes | Yes | No (custom) | No | ~30KB + marked |
| TipTap | Yes | Via plugins | Via plugins | Caution | ~150KB |
Second table — sanitization method comparison:
| Method | XSS Protection | Performance | Complexity |
|---|---|---|---|
| Client-side DOMPurify | 99% | ~2ms per 10KB | Low |
| Server-side CommonMark (strip) | 99.9% | ~1ms per 10KB | Medium |
| Combined | 99.99% | ~3ms per 10KB | Medium |
Quick Start with @uiw/react-md-editor
import MDEditor from '@uiw/react-md-editor';
import { useState } from 'react';
function MarkdownEditor({ initialValue = '', onChange }: EditorProps) {
const [value, setValue] = useState(initialValue);
const handleChange = (val?: string) => {
const markdown = val ?? '';
setValue(markdown);
onChange?.(markdown);
};
return (
<MDEditor
value={value}
onChange={handleChange}
height={400}
preview="live"
hideToolbar={false}
commands={[
MDEditor.commands.bold,
MDEditor.commands.italic,
MDEditor.commands.title,
MDEditor.commands.divider,
MDEditor.commands.link,
MDEditor.commands.image,
MDEditor.commands.code,
MDEditor.commands.codeBlock,
MDEditor.commands.divider,
MDEditor.commands.fullscreen,
]}
/>
);
}
Custom Implementation on CodeMirror 6 + marked.js
import { EditorView, basicSetup } from 'codemirror';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
function createMarkdownEditor(container: HTMLElement, previewContainer: HTMLElement) {
const view = new EditorView({
doc: '',
extensions: [
basicSetup,
markdown(),
oneDark,
EditorView.updateListener.of(update => {
if (update.docChanged) {
const markdown = update.state.doc.toString();
const html = marked(markdown, { breaks: true, gfm: true });
previewContainer.innerHTML = DOMPurify.sanitize(html as string);
}
}),
],
parent: container,
});
return view;
}
Image Upload from Editor
import * as commands from '@uiw/react-md-editor/commands';
const imageUploadCommand: commands.ICommand = {
name: 'upload-image',
keyCommand: 'upload-image',
buttonProps: { 'aria-label': 'Upload image' },
icon: <ImageIcon />,
execute: async (state, api) => {
const file = await openFilePicker(['image/jpeg', 'image/png', 'image/webp']);
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const { data } = await api.post('/api/media/upload', formData);
const imageMarkdown = ``;
api.replaceSelection(imageMarkdown);
},
};
async function openFilePicker(accept: string[]): Promise<File | null> {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = accept.join(',');
input.onchange = () => resolve(input.files?.[0] ?? null);
input.click();
});
}
Why Store Markdown Separately from HTML?
Storing the original Markdown provides flexibility: editable, convertible to different formats (PDF, DOCX), and searchable. HTML is cached for faster delivery. This is standard practice per the CommonMark specification. Additionally, this approach eases content migration between systems.
How to Ensure Secure Rendering?
Sanitize on the server (CommonMark with html_input=strip, max_nesting) and on the client (DOMPurify). Never rely on only one side. The combined approach gives 99.99% protection. A server parser configuration may look like:
$safeHtml = $parser->safeParse($markdown)->getContent();
Process Overview
- Analysis: Determine requirements (GFM, media upload, themes, SSR).
- Design: Library selection, component architecture.
- Implementation: Integration, custom commands (upload, emojis).
- Testing: Unit tests for sanitization, e2e tests for UX.
- Deployment: Cache configuration, error monitoring.
What's Included in the Work
- Library selection and integration.
- Live preview implementation with GFM support.
- Image upload setup (drag&drop, insertion).
- Server-side and client-side sanitization.
- SSR compatibility (if needed).
- Usage and customization documentation.
- 30-day support after deployment.
Timelines and Pricing
Implementation time: 2 to 7 days depending on complexity. Pricing is calculated individually after project analysis. Get a consultation — we will evaluate your case. Contact us — we'll help with selection and implementation.
Checklist for the Completed Editor
- [ ] GFM support (tables, lists, links)
- [ ] Image upload (drag & drop or insertion)
- [ ] Live preview with 100ms debounce
- [ ] Server-side sanitization (html_input=strip, max_nesting)
- [ ] Client-side sanitization (DOMPurify)
- [ ] Store Markdown in DB, cache HTML
- [ ] SSR compatibility (suppressHydrationWarning)
- [ ] Fullscreen mode
- [ ] Syntax highlighting for code blocks
- [ ] Export to HTML/Markdown
Common Implementation Mistakes
- Missing server-side sanitization (XSS risk).
- Ignoring debounce for preview — input lag.
- Storing only HTML (loss of editability).
- Incorrect hydration handling in Next.js with SSR.
Contact us for a consultation — we will help you choose the optimal solution for your project. With us, you get a reliable Markdown editor that meets modern security and performance standards.







