Skip to main content

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 parsingInside the parser
How it worksReplace the placeholder in the source string, then parse the resultParse the source once, then put the value into the tree as text
A value of **Administrator**Becomes boldKeeps its asterisks as characters
A value starting with # at line startBecomes a headingStays text
A value containing ](http://…)Can close the author's link and open its ownStays text
Tools in this familyHandlebars, 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.

One source, two readers, one locale change
Authored templatenever changes
## 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}}
Resolved for Adachanges

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__, constructor and prototype are rejected at resolve time and nothing is written to Object.prototype (path tests).
  • Formatters are number, currency, percent, date, time and datetime, plus any you pass in formatters. currency requires 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 a TEMPLATE_PARTIAL_URL error, 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.

The same template with values from a sign-up form
Authored templatenever changes
## Report for {{customer.name}}

Prepared for {{customer.contact}}.
Resolved for Ordinarychanges

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 })
Schemas and types

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