Skip to main content

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.

Quarterly account review
Authored template never 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 Industrial changes

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

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 \|, which template-table-formatters.test.ts covers.
  • An image, 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, 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.

The same report with a hostile customer name
Authored template never 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 Ordinary changes

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

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