Convert Markdown to HTML for Emails and RSS Feeds

Turn Markdown with components into HTML strings for emails and RSS feeds with @comark/html, including inline-styled components and CDATA output.

Use renderHtml() from @comark/html to turn Markdown into an HTML string, with no framework. Map component tags to functions that return HTML, for example a button with inline styles for an email.

Emails and RSS readers receive static HTML. They don't run your Vue, React, or Svelte components. @comark/html renders each component with a function that you write, so you control the exact markup.

Render Markdown to HTML

Install the package:

Terminal
npm install @comark/html

renderHtml() parses and renders in one call:

newsletter.ts
import { renderHtml } from '@comark/html'

const html = await renderHtml('# October update\n\nWe shipped **dark mode**.')
// <h1 id="october-update">October update</h1>
// <p>We shipped <strong>dark mode</strong>.</p>

When you render many messages or feed items, create the renderer once with createHtmlRenderer(). The returned function reuses the same parser and plugins.

Add an email button component

Write the component as an async function. It receives the node as [tag, attrs, ...children] and a render() helper for the children. Escape attribute values with escapeHtml():

email.ts
import { createHtmlRenderer } from '@comark/html'
import { escapeHtml } from '@comark/html/utils'

export const renderEmail = createHtmlRenderer({
  components: {
    button: async ([, attrs, ...children], { render }) =>
      `<a href="${escapeHtml(String(attrs.href))}" style="display:inline-block;padding:12px 20px;background:#0f172a;color:#ffffff;border-radius:6px;text-decoration:none">${await render(children)}</a>`,
  },
})

The email content uses the component like any other Comark component:

welcome.md
Thanks for signing up.

:button[Confirm your email]{href="https://example.com/confirm"}

@comark/html returns only the content HTML. Put it inside your email layout. Email clients differ in their CSS support. We recommend that you inline the styles of your layout and of the Markdown elements with a separate CSS inliner tool.

Pass per-recipient data

Pass values in the data option. Every component function receives them in its context:

order-email.ts
import { renderHtml } from '@comark/html'
import { escapeHtml } from '@comark/html/utils'

const html = await renderHtml('::greeting\nYour order has shipped.\n::', {
  data: { name: 'Ada' },
  components: {
    greeting: async ([, , ...children], { render, data }) =>
      `<p>Hi ${escapeHtml(String(data?.name))},</p>${await render(children)}`,
  },
})

Build an RSS feed

Put the title and the HTML of each item in CDATA sections, and escape the link URL, so the feed stays valid XML. A CDATA section can't contain ]]>, so split that sequence:

rss.ts
import { createHtmlRenderer } from '@comark/html'

const renderHtml = createHtmlRenderer()

// Keep `]]>` in the value from closing the CDATA section early.
const cdata = (value: string) => `<![CDATA[${value.replaceAll(']]>', ']]]]><![CDATA[>')}]]>`
const escapeXml = (value: string) =>
  value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')

export async function renderItem(post: { title: string, url: string, markdown: string }) {
  const html = await renderHtml(post.markdown)
  return `<item>
  <title>${cdata(post.title)}</title>
  <link>${escapeXml(post.url)}</link>
  <description>${cdata(html)}</description>
</item>`
}

Emails and feed readers can't resolve relative URLs such as /docs. Override the native a tag and resolve each href against your site:

email.ts
import { createHtmlRenderer } from '@comark/html'
import { escapeHtml } from '@comark/html/utils'

export const renderEmail = createHtmlRenderer({
  components: {
    a: async ([, attrs, ...children], { render }) =>
      `<a href="${escapeHtml(new URL(String(attrs.href), 'https://example.com').href)}">${await render(children)}</a>`,
  },
})

Use the same approach for img and its src attribute. If the Markdown comes from users, also add the security plugin with a blockedTags list.

FAQ

Next steps

© 2026 Vercel, Inc.