Skip to main content

Adding a Markdown Editor to Django Forms (No Sync Code Required)

Illustration of a text box with an editor toolbar and a pencil writing in it, beside a green check mark

Most tutorials for adding a markdown editor to Django end the same way:

# forms.py
class PostForm(forms.ModelForm):
    content = forms.CharField(widget=forms.Textarea())
// Then in your template, something like:
const easyMDE = new EasyMDE({ element: document.getElementById('id_content') });

// And before submit, you have to manually sync it back:
document.querySelector('form').addEventListener('submit', () => {
    document.getElementById('id_content').value = easyMDE.value();
});

That extra sync step is the problem. Django's form submission reads the textarea value directly. If your editor replaced the textarea with a custom element and you forget the sync, request.POST['content'] is empty — and you're left wondering why your form saves blank data.

It is also the kind of bug that survives code review. The page looks right, the editor works, the form submits. Only the saved content is missing.

A better approach

markdown-text-editor enhances your textarea instead of replacing it. The original <textarea> stays in the DOM and keeps its name attribute, so Django's form handling works exactly as normal — no sync required.

Everything you already rely on keeps working: request.POST, form validation, ModelForm.save(), CSRF, and pre-filling on edit.

Setup

Install via npm (if you use a JS bundler):

npm install markdown-text-editor

Or use the CDN directly in your template — no build step needed:

<script src="https://cdn.jsdelivr.net/npm/markdown-text-editor"></script>

Django form

Nothing changes in your form class:

# forms.py
from django import forms

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ['title', 'content']
        widgets = {
            'content': forms.Textarea(attrs={'class': 'markdown-editor'}),
        }

Template

{% extends "base.html" %}

{% block content %}
<form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Save</button>
</form>

<script src="https://cdn.jsdelivr.net/npm/markdown-text-editor"></script>
<script>
    new MarkdownEditor('.markdown-editor');
</script>
{% endblock %}

That's it. Submit the form — request.POST['content'] contains the markdown exactly as typed. No hooks, no sync, no custom extraction.

View

Your view doesn't change at all:

# views.py
def create_post(request):
    if request.method == 'POST':
        form = PostForm(request.POST)
        if form.is_valid():
            form.save()  # content is already there, untouched
            return redirect('post-list')
    else:
        form = PostForm()
    return render(request, 'create_post.html', {'form': form})

Editing existing content

When editing an existing post, Django pre-fills the textarea via the form — and the editor picks up whatever value is already there:

def edit_post(request, pk):
    post = get_object_or_404(Post, pk=pk)
    if request.method == 'POST':
        form = PostForm(request.POST, instance=post)
        if form.is_valid():
            form.save()
            return redirect('post-detail', pk=pk)
    else:
        form = PostForm(instance=post)  # textarea pre-filled, editor inherits it
    return render(request, 'edit_post.html', {'form': form})

Validation errors keep the user's text

This is where replacing the textarea usually hurts most. When a form fails validation, Django re-renders the page with the submitted data bound to the form. Because the markdown lives in the textarea, it comes back with it:

def create_post(request):
    form = PostForm(request.POST or None)
    if request.method == 'POST' and form.is_valid():
        form.save()
        return redirect('post-list')
    # Invalid: the same template re-renders with the user's markdown intact
    return render(request, 'create_post.html', {'form': form})

The reader types 800 words, forgets the title, and gets the title error back with every word still in place.

Optional: auto-save draft

const textarea = document.querySelector('.markdown-editor');
const saved = localStorage.getItem('post-draft');
if (saved && !textarea.value) textarea.value = saved;

new MarkdownEditor('.markdown-editor', {
    onChange(value) {
        localStorage.setItem('post-draft', value);
    }
});

Features you get out of the box

  • WYSIWYG hybrid mode — renders bold, italic and headings live while keeping raw Markdown underneath
  • Live preview — side-by-side preview panel
  • Find & ReplaceCtrl+F / Ctrl+H
  • Keyboard shortcutsCtrl+B, Ctrl+I, Ctrl+K, headings via Ctrl+1Ctrl+3, lists via Ctrl+L
  • RTL support — Arabic, Urdu and Farsi work out of the box
  • Dark mode — add data-theme="dark" to any ancestor element
  • XSS safe — preview sanitized with DOMPurify
  • CSP compatible — no inline event handlers
  • ~116KB — against 300KB+ for EasyMDE

Rendering markdown in templates

To display saved markdown in your templates, use the markdown library:

pip install markdown bleach
# models.py
import markdown
import bleach
from django.db import models
from django.utils.safestring import mark_safe

class Post(models.Model):
    content = models.TextField()

    def rendered_content(self):
        html = markdown.markdown(self.content, extensions=['fenced_code', 'tables'])
        return mark_safe(bleach.clean(
            html,
            tags=bleach.sanitizer.ALLOWED_TAGS + ['p', 'h1', 'h2', 'h3', 'pre', 'code', 'table', 'thead', 'tbody', 'tr', 'th', 'td'],
            strip=True
        ))
<!-- detail template -->
<article>
    {{ post.rendered_content }}
</article>

Sanitize on render, not on save. Storing the raw markdown means you can change how it is rendered later — switch extensions, allow a new tag, fix an escaping bug — without a data migration.

Why the textarea matters

A markdown editor that replaces the textarea makes itself part of your form pipeline. Every form that uses it needs the sync step, every new developer has to learn about it, and every forgotten one is a silent data-loss bug. An editor that enhances the textarea stays out of the pipeline entirely: the browser submits the field, Django reads it, and nothing in between has an opinion.

That is the whole design, and it is why the Django integration in this post is three lines long.


Full docs, theming guide, and image upload configuration: markdown-text-editor on Frutjam.

Source on GitHub, package on npm.