Personalized Markdown: one document, every customer
A customer report is written once, by a person, in Markdown. It is read thousands of times, each time with different numbers. Personalized Markdown is that split made explicit: the document is an authored file in version control, and the values arrive at render time.
npm install @react-markdown-kit/renderer @react-markdown-kit/template
The customer report
This is the flagship example from
the specification, section 13.4, built
here with the kit. The left pane is the file a person wrote. The right pane is
a real <Markdown> rendering the document compileMarkdown produced. Switch
the customer, then switch the locale.

## {{period}} account review for {{customer.name}}
Prepared for {{customer.contact}} on {{preparedAt | date:"long"}}.
| Line | Amount |
| --- | ---: |
| Plan, {{plan.seats \| number}} seats | {{plan.price \| currency:"USD"}} |
| Overage | {{plan.overage \| currency:"USD"}} |
| **Total** | **{{plan.total \| currency:"USD"}}** |
Usage sat at {{usage.share | percent:1}} of the included quota. The plan renews
on {{plan.renewsAt | date:"long"}}.
[Open the account]({{links.account}})
Q3 2026 account review for Acme Industrial
Prepared for Dana Okafor on October 1, 2026.
| Line | Amount |
|---|---|
| Plan, 140 seats | $4,800.00 |
| Overage | $312.40 |
| Total | $5,112.40 |
Usage sat at 62.3% of the included quota. The plan renews on November 1, 2026.
Data passed to template()
{
"brand": {
"logoAlt": "Acme Industrial",
"logoUrl": "/img/logo.svg"
},
"period": "Q3 2026",
"customer": {
"name": "Acme Industrial",
"contact": "Dana Okafor"
},
"preparedAt": "2026-10-01",
"plan": {
"seats": 140,
"price": 4800,
"overage": 312.4,
"total": 5112.4,
"renewsAt": "2026-11-01"
},
"usage": {
"share": 0.623
},
"links": {
"account": "https://app.example.com/accounts/acme"
}
}Nothing in the left pane changes between datasets, including the logo, the
account link and the table. The fr-FR and de-DE datasets move the date
format, the grouping separator and the percent sign. They do not move the
currency, which is written into the template as currency:"USD".
Four kinds of binding appear in that one document.
- Text, in a heading and in a paragraph:
{{customer.name}}and{{customer.contact}}. - Formatted numbers and dates:
{{plan.price | currency:"USD"}},{{preparedAt | date:"long"}},{{usage.share | percent:1}}. Inside a table cell the pipe is escaped as\|, whichtemplate-table-formatters.test.tscovers. - An image, alt text and source together:
. - A link destination:
[Open the account]({{links.account}}). The placeholder has to be the whole destination, so the resolved URL can be checked against the protocol policy before it becomes anhref(destination policy tests).
The code
import { compileMarkdown } from '@react-markdown-kit/renderer'
import { gfmPreset } from '@react-markdown-kit/renderer/gfm'
import { template } from '@react-markdown-kit/template'
const source = await readFile('templates/account-review.md', 'utf8')
export function reportFor(customer: Customer) {
return compileMarkdown(source, {
preset: gfmPreset,
extensions: [template({ data: customer.data, locale: customer.locale, timeZone: 'UTC' })],
})
}
In React, hand the compiled document straight to <Markdown>:
<Markdown preset={gfmPreset} document={reportFor(customer)} />
compileMarkdown is the parse and the resolution. <Markdown document> renders
a document that has already been through both, so a report compiled in a
request handler can be rendered without parsing it again.
One source of truth, per customer output
The property that makes this worth doing is that the authored file is never
rewritten. There is no per-customer copy to drift, no branch in the document
for the customer who has no overage, and no "regenerate the templates" step.
git log on account-review.md is the full history of what every customer
was told.
That also means the template is reviewable by the people who own the wording.
@react-markdown-kit/template/editor gives them the same file with every
placeholder as a labelled chip and a sample-data preview, and saves plain
template syntax back
(Authoring templates).
Customer data is not trusted input
Names, contact fields and company names come from sign-up forms. In a template engine that replaces text before parsing, those fields are Markdown source. In this one they are values.

## {{period}} account review for {{customer.name}}
Prepared for {{customer.contact}} on {{preparedAt | date:"long"}}.
| Line | Amount |
| --- | ---: |
| Plan, {{plan.seats \| number}} seats | {{plan.price \| currency:"USD"}} |
| Overage | {{plan.overage \| currency:"USD"}} |
| **Total** | **{{plan.total \| currency:"USD"}}** |
Usage sat at {{usage.share | percent:1}} of the included quota. The plan renews
on {{plan.renewsAt | date:"long"}}.
[Open the account]({{links.account}})
Q3 2026 account review for Acme Industrial
Prepared for Dana Okafor on October 1, 2026.
| Line | Amount |
|---|---|
| Plan, 140 seats | $4,800.00 |
| Overage | $312.40 |
| Total | $5,112.40 |
Usage sat at 62.3% of the included quota. The plan renews on November 1, 2026.
Data passed to template()
{
"brand": {
"logoAlt": "Acme Industrial",
"logoUrl": "/img/logo.svg"
},
"period": "Q3 2026",
"customer": {
"name": "Acme Industrial",
"contact": "Dana Okafor"
},
"preparedAt": "2026-10-01",
"plan": {
"seats": 140,
"price": 4800,
"overage": 312.4,
"total": 5112.4,
"renewsAt": "2026-11-01"
},
"usage": {
"share": 0.623
},
"links": {
"account": "https://app.example.com/accounts/acme"
}
}The second dataset tries to end the paragraph, open a heading, add a payment link, start a table cell and inject a script tag. Every character of it is rendered, as characters, in the place the template put it. Both datasets compile to the same six blocks in the same order, the logo first and the account link last, with no diagnostics.
Values are placed structurally into a parsed tree, never substituted into the source text, and every newline in a value becomes a space, because line-start position is all a block construct needs. The guarantee survives serialization: a resolved document written back to Markdown and re-parsed with GFM has the same node types it had before.
That is 152 test cases across three files:
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). Run them with:
pnpm test -- plugins/template/tests/injection.test.ts \
tests/template-serialization-safety.test.ts \
tests/template-security-independent.test.ts
Validate the data before the report exists
A report with a blank where an amount should be is worse than no report. A
missing required value is an error, and on any error the document becomes your
fallback or nothing
(missing-value tests).
import { z } from 'zod'
const ReviewData = z.object({
period: z.string(),
customer: z.object({ name: z.string(), contact: z.string() }),
plan: z.object({ seats: z.number(), price: z.number(), total: z.number() }),
links: z.object({ account: z.string().url() }),
})
template({ data, schema: ReviewData, fallback: 'This report is temporarily unavailable.' })
schema takes any Standard Schema validator, an
interface Zod, Valibot and ArkType all implement. The validator is recognized
by its ~standard member, so no adapter is needed and the package depends on
none of them
(schema tests).
The same template as an email or a PDF
The root entry imports no React and no Lexical, so the report can be produced by a job as well as by the app:
import { compileMarkdown, documentToMarkdown } from '@react-markdown-kit/renderer'
const document = compileMarkdown(source, { preset: gfmPreset, extensions: [template({ data })] })
await sendEmail({ to: customer.email, markdown: await documentToMarkdown(document) })
Serialization re-escapes the resolved values, so the Markdown that leaves the
process reads the same way the rendered document did
(serializer tests).
scripts/pack-check.mjs
resolves a template from the packed tarball in a project with no Lexical
installed.
FAQ
What is personalized Markdown?
One authored Markdown document with placeholders, rendered once per reader with that reader’s data. The source file is never rewritten: @react-markdown-kit/template resolves the placeholders while the renderer parses, so one template serves every customer.
How do I render a different Markdown document per customer?
Compile the same source with a different data object: compileMarkdown(source, { extensions: [template({ data: customer })] }). Pass locale and timeZone alongside the data to move dates, number grouping and percent signs without touching the template.
Can a customer’s own name break the layout of the report?
No. A resolved value becomes a text node in a parsed tree, so it cannot open a heading, a table row, a link or an HTML tag, and it still cannot after the document is serialized back to Markdown and re-parsed. 152 test cases assert it.
Can the same template produce an email or a PDF?
Yes. The root entry imports no React and no Lexical, so compileMarkdown plus documentToMarkdown produces resolved Markdown text in a Node service, a worker, a CLI or a PDF pipeline, from the same file the React app renders.
Next
Markdown template engine · Markdown template variables · Compared with Handlebars and Mustache · Schemas and types · Editor demo · @react-markdown-kit/template on npm · Source on GitHub