Get Started for FREE

Reading and Writing Markdown in Froala Editor

Reading and Writing Markdown in Froala Editor

Froala v5.4.0 adds getMarkdown() and setMarkdown(), so Markdown can be the format your application stores rather than something you convert once on the way out. Before you wire them up, it helps to understand how Froala keeps Markdown content separate from rich text content. That design protects your users’ work, and it explains exactly what each of these two methods reads, writes, and returns.

Key Takeaways

  • Markdown mode and Rich Text mode each hold their own content, so switching between them is never destructive.
  • getMarkdown() and setMarkdown() work only with the Markdown side of the editor.
  • A null return from setMarkdown() means the value was cached, not that the call failed.
  • getMarkdown() fully replaces the older custom export button built on selection.text().

How Froala Separates Markdown Content From Rich Text Content

Froala gives you two modes, and each one holds its own content:

  • Rich Text mode, the familiar WYSIWYG experience where users type and format with the toolbar.
  • Markdown mode, where users write Markdown syntax directly.

The markdown toolbar button switches between them. Switching does not move or convert content. Whatever you left in Rich Text mode is still there when you come back, and the same is true of Markdown mode. Each mode owns its content for the whole life of the editor session.

Why the two are kept separate

This is a deliberate design decision, and it exists to protect content.

HTML expresses far more than Markdown can. Rich text content routinely holds tables with merged cells, styled spans, custom classes, embedded media, and inline attributes that have no Markdown equivalent. If the toolbar button converted automatically on every switch, each toggle would quietly discard whatever could not be represented in the target format. Flip to Markdown and back, and you would not return to where you started. A user checking the Markdown view out of curiosity would pay for it with degraded formatting.

Keeping the two separate means toggling is always safe. A user can move between modes as often as they like, and nothing is lost in either direction. The tradeoff is that content does not travel between modes on its own, which is why getMarkdown() and setMarkdown() exist as explicit, deliberate operations on the Markdown side.

Inside each mode, the layout differs.

Rich Text mode has two views you can flip between with the Code View toolbar button. The Rich Text view shows formatted content. The Code View shows the underlying HTML. These two views do convert content between them: edit formatted text, switch to Code View, and you see the HTML for it. Edit that HTML, switch back, and you see it rendered.

Markdown mode has a single view split into two panes. The Markdown pane is where you write. The result pane beside it shows a live preview of what your Markdown renders to. The preview pane is view-only, so users edit Markdown in one pane and watch the result in the other.

Here is the whole picture:

Where you are What it shows Editable
Rich Text view Formatted content Yes
Code View HTML for that content Yes
Markdown pane Markdown syntax Yes
Preview pane Rendered result No

In practice this means a document belongs to one mode. Since nothing carries content across, you decide up front whether a given piece of content is a Markdown document or a rich text document, and your users work in that mode.

Set Up the Editor

Everything below builds on this initialization.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Froala Markdown Demo</title>

  <!-- Core Froala Editor styles -->
  <link
    href="https://cdn.jsdelivr.net/npm/froala-editor@5.4.0/css/froala_editor.pkgd.min.css"
    rel="stylesheet"
  />
  <!-- Markdown plugin styles -->
  <link
    href="https://cdn.jsdelivr.net/npm/froala-editor@5.4.0/css/plugins/markdown.min.css"
    rel="stylesheet"
  />
</head>
<body>
  <div id="editor"></div>
  <button id="download-md">Download .md</button>

  <!-- Core Froala Editor script -->
  <script src="https://cdn.jsdelivr.net/npm/froala-editor@5.4.0/js/froala_editor.pkgd.min.js"></script>
  <!-- Markdown plugin script -->
  <script src="https://cdn.jsdelivr.net/npm/froala-editor@5.4.0/js/plugins/markdown.min.js"></script>

  <script>
    const editor = new FroalaEditor('#editor', {
      // Required. Without this, editor.markdown is undefined.
      pluginsEnabled: ['markdown'],
      // The 'markdown' button switches the user between the two modes.
      toolbarButtons: ['bold', 'italic', 'underline', '|', 'markdown'],
      events: {
        'markdown.beforeSet': function (markdown) {
          // Return false here to reject the write entirely.
          if (!markdown || !markdown.trim()) {
            console.warn('Refusing to load empty Markdown.');
            return false;
          }
        },
        'markdown.set': function () {
          console.log('Markdown content applied.');
        },
        'markdown.afterGet': function (markdown) {
          console.log('Markdown read, length:', markdown.length);
        }
      }
    });
  </script>
</body>
</html>

Load Markdown Content Into the Editor

This is the inbound half of the workflow. Content arrives as a Markdown string from a CMS field, a GitHub README, or a generated draft, and you need it inside the editor.

setMarkdown() takes that string and writes it to the Markdown side of the editor, replacing whatever was there.

async function loadDraft(editor, draftId) {
  const response = await fetch(`/api/drafts/${draftId}`);
  const draft = await response.json();

  const result = editor.markdown.setMarkdown(draft.markdownBody);

  if (result === false) {
    // Either the value wasn't a string, or markdown.beforeSet cancelled it.
    console.error('Draft content was rejected.');
    return;
  }

  // result is true if Markdown mode was on and the content is now visible.
  // result is null if Markdown mode was off and the content was cached.
  console.log('Draft loaded. Applied immediately:', result === true);
}

Understanding the three return values

setMarkdown() gives you one of three answers, and each one tells you something specific:

  • true means Markdown mode was enabled. The content was applied immediately and an undo step was saved, so the user can reverse it.
  • null means Markdown mode was disabled. The value was cached and will be applied the next time Markdown mode is turned on. It can also be read back through getMarkdown() in the meantime. Nothing visible happens at call time.
  • false means the value you passed was not a string, or a markdown.beforeSet listener returned false and cancelled the write.

The null case is worth dwelling on, because it is doing more work than it first appears.

When you call setMarkdown() from Rich Text mode, the editor has a choice to make. It could refuse the write. It could force the user into Markdown mode so the content has somewhere visible to land. Froala does neither. It accepts the content, holds it, applies it the moment Markdown mode next opens, and hands you a distinct return value saying so. The user is not yanked out of the view they were working in, and your content is not dropped.

That is why the return type is true, null, or false rather than a plain boolean. Three states tell you three different things: applied and visible, accepted and waiting, or rejected. You can branch on all three.

Treat null as success. The only failure signal is false.

Repopulate on every initialization

Markdown content does not survive across page loads or editor re-initialization. Every fresh editor starts with the Markdown side empty until something calls setMarkdown().

That makes this call part of your setup path, not just something behind a “Load draft” button:

const editor = new FroalaEditor('#editor', {
  pluginsEnabled: ['markdown'],
  toolbarButtons: ['bold', 'italic', 'underline', '|', 'markdown'],
  events: {
    initialized: function () {
      // Restore stored Markdown as soon as the editor is ready.
      const stored = window.localStorage.getItem('draft-markdown');
      if (stored) {
        this.markdown.setMarkdown(stored);
      }
    }
  }
});

If you skip this, users who refresh the page find their Markdown gone, which reads as data loss even though nothing was ever persisted in the first place.

Save Markdown Content as a .md File

Now the outbound half. getMarkdown() returns the Markdown content as a string.

Be clear about what it reads. It returns the Markdown side of the editor, which means it gives you real content when the document was authored in Markdown mode or populated through setMarkdown(). It does not convert a user’s rich text into Markdown. If someone typed formatted content in Rich Text mode and you call getMarkdown(), you get whatever is on the Markdown side, which may well be empty.

The method also behaves differently depending on the current mode. With Markdown mode enabled you get the live content. With Markdown mode disabled you get the last cached value, and the editor’s HTML is never consulted.

function downloadMarkdown(editor, filename = 'content.md') {
  const markdown = editor.markdown.getMarkdown();

  // A markdown.beforeGet listener can cancel the read.
  if (markdown === false) {
    console.warn('Markdown read was cancelled.');
    return;
  }

  const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
  const url = URL.createObjectURL(blob);

  const link = document.createElement('a');
  link.href = url;
  link.download = filename;
  link.click();

  URL.revokeObjectURL(url);
}

document.getElementById('download-md').addEventListener('click', function () {
  downloadMarkdown(editor, 'article.md');
});

Always check for false before writing to a file. A cancelled read returns the boolean, and passing that straight into a Blob produces a file containing the text “false”.

This is what makes docs-as-code workflows practical. The .md file that comes out is diffable in Git, reviewable in a pull request, and consumable by any static site generator, without the HTML noise that makes content diffs unreadable.

The markdown.afterGet event fires on every successful read and receives the string, which makes it a convenient hook for logging exports or triggering analytics.

Replace the Custom Export to Markdown Button

If you followed our earlier guide to adding a custom Export from Markdown button, you built a toolbar button that worked around the absence of a getter. Retrieving the content took four calls:

// The older pattern, before getMarkdown() existed.
this.selection.save();
this.commands.selectAll();
const markdownContent = this.selection.text();
this.selection.restore();

That button also needed a refresh handler checking markdown.isEnabled(), so it could disable itself outside Markdown mode, plus an SVG entry, a DefineIcon call, and a RegisterCommand block.

All of it collapses into one line:

const markdown = editor.markdown.getMarkdown();

Both approaches require Markdown mode, so this is a like-for-like swap rather than a new capability. What you gain is less code to own, a cursor that is never touched, and the markdown.beforeGet and markdown.afterGet events, which the selection-based approach had no way to offer. The download half of the old callback still applies unchanged, and it is the same Blob code shown earlier.

Common Pitfalls

Know which mode you are in before you save. The getters follow the active mode, so html.get() returns Markdown text while Markdown mode is on. That consistency is useful once you expect it, since a single save handler can serve both modes as long as it records which format it received. Branch on the current mode and store the format alongside the content:

function getContentForStorage(editor) {
  return editor.markdown.isEnabled()
    ? { format: 'markdown', body: editor.markdown.getMarkdown() }
    : { format: 'html', body: editor.html.get() };
}

setMarkdown() replaces the Markdown content rather than appending to it. That is the documented behavior and it matches html.set(), but it applies to cached writes too. If you call it while Markdown mode is off, the queued value will replace whatever the user had written there once they switch back. Prompt for confirmation when you are loading over content a user may have authored.

Conclusion

The single thing to remember is that Markdown content and rich text content live separately in Froala, so a user can move between modes without losing anything in either one. Every part of the API follows from that: getMarkdown() and setMarkdown() operate on the Markdown side deliberately, null tells you a write is waiting for a mode that is not open yet, and the getters report the mode you are actually in. Build around it and Markdown becomes a first-class storage format rather than a one-way export. For the complete method and event reference, see the Froala Markdown plugin documentation.

FAQ

Why does setMarkdown() return null?

Markdown mode was off when you called it, so rather than dropping your content or forcing the user into a different view, the editor cached the value. It applies the next time Markdown mode turns on, and you can read it back with getMarkdown() in the meantime. The write succeeded. Only false indicates a rejected call.

Does switching between Markdown mode and Rich Text mode convert my content?

No, and that is intentional. HTML can express formatting that Markdown cannot, so converting on every toggle would discard a little more of your content each time. Keeping the two separate means switching is always safe. You may be thinking of Code View, which is a different feature and does convert between formatted content and HTML within Rich Text mode.

Can I load a Markdown string and let users edit it as formatted rich text?

Convert it to HTML in your application first, with a library such as marked.js, then call html.set(). setMarkdown() writes to the Markdown side of the editor, which is where users edit Markdown syntax directly.

Does Markdown content persist after a page refresh?

No. Every editor initialization starts with the Markdown side empty. Store the string yourself and call setMarkdown() when the editor initializes.
graphical user interface, text

Posted on August 28, 2026

Mostafa Yousef

Senior web developer with a profound knowledge of the Javascript and PHP ecosystem. Familiar with several JS tools, frameworks, and libraries. Experienced in developing interactive websites and applications.

No comment yet, add your voice below!


Add a Comment

Your email address will not be published. Required fields are marked *