Skip to main content

Markdown templating: React Markdown Kit versus Handlebars and Mustache

Handlebars and Mustache are string templating engines. They take a string with {{placeholders}}, put values in, and hand back a string. When that string is Markdown, the values are part of the Markdown source before anything parses it.

@react-markdown-kit/template is not a templating engine. It is an extension to a Markdown parser. The document is parsed first, and values are placed into the syntax tree afterwards, as text nodes.

That single difference decides everything below.

The same template, the same data, two results

Here is the document, with the hostile value coming from a contact field on a sign-up form.

## Account review for {{customer.name}}

Prepared for {{customer.contact}}.

Thanks for being with us.

String interpolation, then parse

The pane below is the real output of replacing the placeholders in the source and parsing the result, with the value escaped the way Handlebars escapes it by default (HTML escaping: &, <, >, ", ', ` and =). The substitution runs on this page; the left pane is its result, and you can edit it to see how the parser reads it.

Value spliced into the source, then parsed
Markdown (edit me)
Rendered

Account review for Acme Industrial

Prepared for Dana

Your account is suspended

Wire payment to our new bank. .

Thanks for being with us.

The paragraph the author wrote was closed halfway through, a heading the author did not write appeared, and a link to another host was added under it. No HTML was involved, so HTML escaping did nothing: #, [, ], ( and ) are not in the escape set, because Handlebars escapes for HTML and this document is Markdown.

Parse, then resolve

Same template, same data, @react-markdown-kit/template.

Value placed into the parsed tree
Authored template never changes
## Account review for {{customer.name}}

Prepared for {{customer.contact}}.

Thanks for being with us.
Resolved for Ordinary contact changes

Account review for Acme Industrial

Prepared for Dana Okafor.

Thanks for being with us.

Data passed to template()
{
  "customer": {
    "name": "Acme Industrial",
    "contact": "Dana Okafor"
  }
}

Three blocks stay three blocks. Every character of the value is rendered, as characters, inside the paragraph the author wrote.

Why escaping does not close the gap

The obvious repair is to escape the value for Markdown before handing it to Handlebars. It is harder than it looks, and the reason is worth stating.

  • The characters that matter depend on position. A # is a heading only at the start of a line, - is a list only at line start, | is a table only inside a table row, and --- under a line of text is a setext heading. An escaper that does not know where the value lands cannot know what to escape.
  • A newline is enough. Any construct above needs only line-start position, so a value containing \n can reach it from the middle of a paragraph. The plugin turns every newline in a value into a space for exactly this reason (test).
  • Escaping twice is visible. Escape every * and a customer named A*Star becomes A\*Star in the emailed Markdown that someone re-parses downstream, or A\*Star on screen if the escape does not survive.
  • The dialect is not fixed. Tables, footnotes and strikethrough exist with GFM and not with CommonMark, so the escape set changes with the preset.

Resolving inside the parser sidesteps the whole problem: the value is never source, so there is nothing to escape, and the serializer re-escapes on the way out because at that point it knows the context (serializer tests).

Side by side

Handlebars and Mustache@react-markdown-kit/template
What it templatesAny stringA parsed Markdown document
When values are insertedBefore parsingAfter parsing, into the tree
A value can create Markdown structureYesNo (77 cases)
A value can create HTMLEscaped by default; raw with {{{triple}}} or {{& x}}No. A value cannot create an HTML node (test), and a placeholder inside raw HTML is never bound (tests)
Survives serialize and re-parseNot a property of the engineYes (62 cases)
Loops and conditionalsYes, sections and block helpersNo
Custom helpersYes, arbitrary functionsFormatters only, value in and string out
Number, date and currency formattingBring your own helperBuilt in, locale and time zone aware (tests)
Runtime validation of the dataBring your ownschema takes any Standard Schema validator (tests)
Behaviour when a value is missingRenders empty by defaultError diagnostic; the document becomes fallback or nothing (tests)
Placeholders inside code blocksReplacedLeft literal (6 code-context cases)
Prototype-chain pathsHandlebars ships runtime options to control prototype accessRefused at parse and again at lookup (18 prototype-path cases in a 33-case file)
OutputA string you still have to renderA compiled document, or Markdown text through documentToMarkdown

The evidence column runs as one command:

pnpm test -- plugins/template/tests/ tests/template-table-formatters.test.ts \
tests/template-serialization-safety.test.ts tests/template-security-independent.test.ts

That run is 324 cases across 15 files, of which 152 are injection cases.

Mustache is the same story

Mustache's {{name}} is HTML-escaped and {{{name}}} is not (mustache(5)), which is the same escape boundary Handlebars draws. Mustache has sections rather than block helpers and no helper functions, so it does less than Handlebars and behaves identically for the purpose of this page: values are spliced into the source string, and Markdown structure inside a value becomes Markdown structure.

When to stay on Handlebars

This is not a replacement for Handlebars. Stay with it if any of these apply.

  • The document needs iteration or branching. A table with one row per line item, or a paragraph that only appears for trial accounts, needs {{#each}} and {{#if}}. The plugin has neither: a placeholder is a dotted path with an optional formatter, and a(), a[0], a b and 1 + 1 are all rejected as paths (parser tests). In React the usual answer is to loop in the component and render one <Markdown> per item, but that moves the loop out of the document.
  • You are templating something that is not Markdown, such as an HTML email, a YAML file or a subject line.
  • Non-developers already write Handlebars in your product and the syntax is part of the contract.
  • You need helpers with arbitrary logic. The plugin's formatters take one value and return a string; there is no {{#compare}}.

The two coexist. A common arrangement is Handlebars for the surrounding machinery, such as the subject line and the HTML wrapper, and the plugin for the Markdown body, where the untrusted values are.

Migrating a Markdown template

The placeholder syntax matches, so most templates move unchanged.

  1. {{customer.name}} stays {{customer.name}}.
  2. {{{customer.name}}} becomes {{customer.name}}. There is no unescaped form, because nothing is escaped: the value is never source.
  3. Handlebars helpers become formatters: {{formatCurrency amount}} becomes {{amount | currency:"USD"}}, with the code explicit because a locale never implies a currency (test).
  4. {{#each}} and {{#if}} have no equivalent. Either keep those documents on Handlebars or move the branch into the code that picks the template.
  5. Replace Handlebars.compile(source)(data) and a separate Markdown render with one call:
import { compileMarkdown } from '@react-markdown-kit/renderer'
import { gfmPreset } from '@react-markdown-kit/renderer/gfm'
import { template } from '@react-markdown-kit/template'

const document = compileMarkdown(source, { preset: gfmPreset, extensions: [template({ data })] })

Placeholders you wrote about rather than with, inside code fences, stop being replaced, which is usually the fix for a documentation page that Handlebars kept eating (literal-context tests).

FAQ

Can I use Handlebars to template a Markdown file?

Yes, and it works while you write both the template and the data. Handlebars replaces the placeholder in the source string and the result is then parsed as Markdown, so a value can add a heading, a list, a table row or a link. That is fine for values you control and a hazard for values a user typed.

Does Handlebars escaping protect a Markdown document?

Only against HTML. Handlebars and Mustache escape the HTML metacharacters by default, which stops a script tag, but asterisks, hashes, pipes, brackets and parentheses are untouched, and those are the characters that carry Markdown structure.

What does React Markdown Kit do differently?

It parses the document first and then places values into the syntax tree as text nodes, so a value is never source. The document keeps its shape whatever the data says, which 152 test cases assert, including after the result is serialized back to Markdown and re-parsed.

Does the template plugin have loops and conditionals?

No. It resolves dotted paths with optional formatters, and nothing else: no sections, no helpers, no expressions. If a document needs iteration or branching, Handlebars has it and this plugin does not.

Next

Markdown template engine · Personalized Markdown · Markdown template variables · Security model · Editor demo · @react-markdown-kit/template on npm · Source on GitHub