Skip to main content

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.

Quarterly account review
Authored templatenever changes
![{{brand.logoAlt}}]({{brand.logoUrl}})

## {{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}})
Resolved for Acme Industrialchanges

Acme Industrial

Q3 2026 account review for Acme Industrial

Prepared for Dana Okafor on October 1, 2026.

LineAmount
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.

Open the account

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 by template-table-formatters.test.ts).
  • An image, with alt text and source together: ![{{brand.logoAlt}}]({{brand.logoUrl}}).
  • 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 an href (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.

The same report with a hostile customer name
Authored templatenever changes
![{{brand.logoAlt}}]({{brand.logoUrl}})

## {{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}})
Resolved for Ordinarychanges

Acme Industrial

Q3 2026 account review for Acme Industrial

Prepared for Dana Okafor on October 1, 2026.

LineAmount
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.

Open the account

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
How that differs from Handlebars and Mustache

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