Personalized Markdown for every customer from one document
A customer report gets written once, by a person, in Markdown. Then it's read thousands of times, each time with different numbers. Personalized Markdown just makes that split explicit. The document is an authored file in version control, and the values come in at render time.
npm install @react-markdown-kit/renderer @react-markdown-kit/template
The customer report
This is the main 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
an actual <Markdown> rendering the document compileMarkdown produced. Try
switching the customer, then 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.
Nothing in the left pane changes between datasets, including the logo, the
account link and the table. The fr-FR and de-DE datasets change the date
format, the grouping separator and the percent sign. The currency stays the
same, since it's written into the template as currency:"USD".
That one document uses four kinds of binding.
- 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\|(covered bytemplate-table-formatters.test.ts). - An image, with 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, pass the compiled document straight to <Markdown>:
<Markdown preset={gfmPreset} document={reportFor(customer)} />
compileMarkdown does 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 later without parsing it again.
One source file, many customer outputs
The main reason to do it this way is that the authored file never gets
rewritten. You don't end up with per-customer copies that drift apart, a branch
in the document for the customer with no overage, or a "regenerate the
templates" step. git log on account-review.md gives you the full history of
what every customer was told.
It also means the people who own the wording can review the template.
@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 untrusted input
Names, contact fields and company names come from sign-up forms. A template engine that replaces text before parsing treats those fields as Markdown source. This one treats them as 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.
The second dataset tries to end the paragraph, open a heading, add a payment link, start a table cell and inject a script tag. All of it gets rendered as plain characters, wherever the template put it. Both datasets compile to the same six blocks in the same order (logo first, account link last) with no diagnostics.
Values are placed into a parsed tree as nodes and never substituted into the source text. Every newline in a value also becomes a space, since a block construct only needs to land at the start of a line. This still holds after serialization. A resolved document written back to Markdown and re-parsed with GFM has the same node types it had before.
There are 152 test cases for this 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 at
all. 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
(Zod, Valibot and ArkType all implement it). The validator is recognized by its
~standard member, so you don't need an adapter and the package doesn't depend
on any of them
(schema tests).
The same template as an email or a PDF
The root entry doesn't import React or Lexical, so a background job can produce the report as well as 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 goes out
reads the same way the rendered document did
(serializer tests).
scripts/pack-check.mjs
checks this by resolving a template from the packed tarball in a project that
doesn't have Lexical installed.
FAQ
What is personalized Markdown?
It's a single 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 covers every customer.
How do I render a different Markdown document per customer?
Compile the same source with a different data object, e.g. compileMarkdown(source, { extensions: [template({ data: customer })] }). If you pass locale and timeZone alongside the data, dates, number grouping and percent signs change to match 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 can't open a heading, a table row, a link or an HTML tag. That still holds after the document is serialized back to Markdown and re-parsed, and there are 152 test cases checking it.
Can the same template produce an email or a PDF?
Yes. The root entry doesn't import React or Lexical, so you can use compileMarkdown plus documentToMarkdown to get 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