Markdown template variables
The same three questions tend to come up when someone wants one Markdown file to say different things for different readers. Short answers first, then how this kit handles it.
Can Markdown have variables? No. Neither CommonMark nor GitHub Flavored
Markdown defines variables, placeholders or expressions, so any "variable"
you've seen in a .md file was added by some tool that runs around Markdown.
How do I inject variables into a .md file? Put a placeholder in the file
that the parser reads as ordinary text, such as {{user.name}}, and resolve it
when the document is rendered instead of rewriting the file.
What are placeholders in Markdown? They're a convention (Markdown itself
doesn't have them). Double braces are the usual choice. { and } mean nothing
in Markdown, so {{user.name}} is parsed as literal text and stays intact
through any Markdown tool that touches the file.
The two places a variable can be resolved
There are really only two options, and which one you pick decides what a value is able to do.
| Before parsing | Inside the parser | |
|---|---|---|
| How it works | Replace the placeholder in the source string, then parse the result | Parse the source once, then put the value into the tree as text |
A value of **Administrator** | Becomes bold | Keeps its asterisks as characters |
A value starting with # at line start | Becomes a heading | Stays text |
A value containing ](http://…) | Can close the author's link and open its own | Stays text |
| Tools in this family | Handlebars, Mustache, Jinja, sed, template literals | @react-markdown-kit/template |
The first column isn't a bug in those tools. They template strings, and Markdown is a string. It only becomes a problem when the values come from someone other than the person who wrote the document. Markdown templating versus Handlebars goes through that comparison with the tests.
The plugin's way
npm install @react-markdown-kit/renderer @react-markdown-kit/template
import Markdown from '@react-markdown-kit/renderer'
import { template } from '@react-markdown-kit/template'
const source = '# Hello {{user.name}}\n\nYour balance is {{balance | currency:"USD"}}.'
<Markdown extensions={[template({ data: { user: { name: 'Ada' }, balance: 4200 } })]}>
{source}
</Markdown>
There's no engine object to create. template({ data }) is a renderer
extension, so the renderer parses your source with your preset and the plugin
fills in the parsed tree. The same extension runs outside React:
import { compileMarkdown } from '@react-markdown-kit/renderer'
const document = compileMarkdown(source, { extensions: [template({ data })] })
Try switching the dataset below. The source is the same string for every customer.
## Welcome back, {{user.firstName}}
Your plan renews on {{plan.renewsAt | date:"long"}} for {{plan.price \| currency:"USD"}}.
- Seats in use: {{usage.seats | number}}
- Storage used: {{usage.storage | percent:1}}
Welcome back, Ada
Your plan renews on November 1, 2026 for $480.00.
- Seats in use: 14
- Storage used: 62.3%
The locale option changes the date, the grouping separator and the percent
sign. The currency code stays the same because it's written in the template.
Placeholder syntax
{{path.to.value}}
{{path.to.value | formatter}}
{{path.to.value | formatter:"argument"}}
- Paths are dotted property names and array indexes, read as own
properties only.
__proto__,constructorandprototypeare rejected at resolve time and nothing is written toObject.prototype(path tests). - Formatters are
number,currency,percent,date,timeanddatetime, plus any you pass informatters.currencyrequires an explicit ISO 4217 code, since a locale doesn't imply one (currency tests). - Escaping. Write
\{{not.a.placeholder}}to keep the braces literal. The backslash is removed and nothing is bound (escape tests). - Code is literal. A placeholder inside inline code, a fenced block or an indented block is never bound, and the variable doesn't even need to exist (6 code-context cases).
- A placeholder has to be the whole URL.
[Open]({{links.account}})works, but[Open](https://app.example/{{id}})is aTEMPLATE_PARTIAL_URLerror, because once a URL is half-built you can't reliably check its protocol (partial-URL tests).
Required, optional and missing
A placeholder with no value is an error by default. On any error the document
becomes your fallback (or nothing at all), so a report doesn't go out with a
blank where an amount should be.
<Markdown
extensions={[
template({
data,
variables: { 'plan.price': { required: true }, 'user.middleName': { required: false } },
fallback: 'This report is temporarily unavailable.',
onDiagnostics: (diagnostics) => logger.warn('report', { diagnostics }),
}),
]}
>
{source}
</Markdown>
Every diagnostic has a stable code and the data path it's about, but never the
runtime value, so diagnostics are safe to log. TEMPLATE_REQUIRED_VALUE is an
error, and TEMPLATE_OPTIONAL_VALUE_MISSING is a warning that resolves to empty
text. The full list is in Template basics.
Why a value cannot become structure
A resolved value is inserted as a text node in a tree that's already been parsed, and nothing re-parses it. Newlines inside a value are turned into spaces first, because a block construct only needs to be at the start of a line to form. The document is also re-escaped on serialization, so this still holds after a round trip through Markdown text.
## Report for {{customer.name}}
Prepared for {{customer.contact}}.
Report for Acme Industrial
Prepared for Dana Okafor.
Switching the dataset changes the words but not the structure. Every case renders two blocks, a heading and a paragraph.
This is covered by 152 test cases:
injection.test.ts
(22 hostile values, 77 cases),
template-serialization-safety.test.ts
(12 line-start constructs in 5 authored contexts, 62 cases) and
template-security-independent.test.ts
(13 cases, written without the plugin's own helpers).
pnpm test -- plugins/template/tests/injection.test.ts \
tests/template-serialization-safety.test.ts \
tests/template-security-independent.test.ts
Typing the data
TypeScript will catch a wrong shape at compile time:
template<ReportData>({ data })
A runtime schema catches it when the data comes in. The schema option takes any
Standard Schema validator (Zod, Valibot and ArkType
all implement it). The validator is detected by its ~standard member, so you
don't need an adapter and the package doesn't depend on any of them. Two vendors, one with array issue paths and one with object issue paths,
are tested against the same template in
schema.test.ts.
import { z } from 'zod'
const ReportSchema = z.object({ customer: z.object({ name: z.string() }) })
template({ data, schema: ReportSchema })
Getting the Markdown back out
If you need an email body or a file instead of React elements, serialize the compiled document:
import { compileMarkdown, documentToMarkdown } from '@react-markdown-kit/renderer'
const document = compileMarkdown(source, { extensions: [template({ data })] })
await writeFile('report.md', await documentToMarkdown(document))
The root entry doesn't import React or Lexical, so this runs fine in a service,
a worker, a CLI or a PDF job
(scripts/pack-check.mjs
resolves a template from the packed tarball with no Lexical installed).
FAQ
Can Markdown have variables?
Not on its own. CommonMark and GitHub Flavored Markdown have no variable, placeholder or expression syntax, so variables are always something a tool adds on top of Markdown, either before parsing or during it.
How do I inject variables into a .md file?
Write a placeholder the parser treats as ordinary text, such as {{user.name}}, leave it in the file, and resolve it at render time. You can also do string replacement on the .md file before parsing, but that approach lets a value turn into Markdown structure.
What is the syntax for placeholders in Markdown?
There isn't a standard one. Double braces are the usual convention (Handlebars, Mustache, Jinja and this plugin all use them). Braces have no meaning in Markdown, so the placeholder comes through parsing as plain text.
Do placeholders inside code blocks get replaced?
Not with @react-markdown-kit/template. A placeholder inside inline code, a fenced block or an indented block is left exactly as written, so a page can still document template syntax with the plugin installed. Six cases in the code-contexts block of literal-contexts.test.ts check this, out of 13 cases in the file.
Next
Markdown template engine · Personalized Markdown · Compared with Handlebars and Mustache · Template basics · Authoring in the editor · Editor demo · @react-markdown-kit/template on npm · Source on GitHub