Skip to main content

Markdown template variables

Three questions come up whenever someone wants one Markdown file to say different things for different readers. Here are the short answers, then the way this kit does it.

Can Markdown have variables? No. Neither CommonMark nor GitHub Flavored Markdown defines a variable, a placeholder or an expression, so every "variable" you have seen in a .md file was added by a tool around Markdown, not by 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 rather than by rewriting the file.

What are placeholders in Markdown? A convention, not a feature. Double braces are the usual choice, because { and } mean nothing in Markdown, so {{user.name}} is parsed as literal text and stays intact through every Markdown tool that touches the file.

The two places a variable can be resolved

There are only two, and the choice decides what a value is allowed 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 is not a bug in those tools. They template strings, and Markdown is a string. It only becomes a problem when the values are not written by the same 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 is no engine object. template({ data }) is a renderer extension, so the renderer parses your source with your preset and the plugin fills the parsed tree in. The same extension runs outside React:

import { compileMarkdown } from '@react-markdown-kit/renderer'

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

Switch the dataset below. The source is the same string for every customer.

One source, two readers, one locale change
Authored template never 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 Ada changes

Welcome back, Ada

Your plan renews on November 1, 2026 for $480.00.

  • Seats in use: 14
  • Storage used: 62.3%
Data passed to template()
{
  "user": {
    "firstName": "Ada"
  },
  "plan": {
    "renewsAt": "2026-11-01",
    "price": 480
  },
  "usage": {
    "seats": 14,
    "storage": 0.623
  }
}

The locale option moves the date, the grouping separator and the percent sign. The currency code never moves: it is 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 refused 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; a locale never implies 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 it does not even need the variable to exist (6 code-context cases).
  • A URL binding is all or nothing. [Open]({{links.account}}) works; [Open](https://app.example/{{id}}) is a TEMPLATE_PARTIAL_URL error, because a half-built URL is where a protocol check stops being possible (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 never goes 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 carries a stable code and the data path it concerns, never the runtime value, so diagnostics are safe to log. TEMPLATE_REQUIRED_VALUE is an error; 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 an already-parsed tree. Nothing re-parses it. Every newline inside a value becomes a space first, because a block construct needs only line-start position to form, and the document is re-escaped on serialization, so the guarantee survives a round trip through Markdown text.

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

Prepared for {{customer.contact}}.
Resolved for Ordinary changes

Report for Acme Industrial

Prepared for Dana Okafor.

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

Switching dataset changes the words and never the shape: two blocks, a heading and a paragraph, in every case.

That claim is 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 catches a wrong shape at compile time:

template<ReportData>({ data })

A runtime schema catches it at the boundary. The schema option takes any Standard Schema validator, an interface Zod, Valibot and ArkType all implement, and the validator is detected by its ~standard member, so no adapter is needed and the package depends on none of them. Two vendors, one with array issue paths and one with object issue paths, are run 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

When the output is an email body or a file rather than 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 imports no React and no Lexical, so this runs 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, no placeholder and no expression syntax, so a variable is always something a tool adds on top of the 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}}, keep it in the file, and resolve it at render time. Editing the .md file itself, by string replacement before parsing, is the approach that lets a value become Markdown structure.

What is the syntax for placeholders in Markdown?

There is no standard one. Double braces are the common convention, shared by Handlebars, Mustache, Jinja and this plugin, because a brace has no meaning in Markdown, so the placeholder survives 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 document template syntax while the plugin is installed. Six cases in the code-contexts block of literal-contexts.test.ts assert it, 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