Skip to content

Prototype React Email templates: render once with Liquid or render per send #528

Closed

Description

@Silthus

Question

How should React Email templates reach a send: rendered once into a stored, code-managed template with Liquid placeholders, or rendered on every send?

Michael (2026-10-20) wants React Email templates to work directly from code, as with Resend's react field. The map stores templates by key with a content hash, fills {{ variables.* }} from data at send time, and shows the template read-only in the UI (PostHog#118924, posthog-node PostHog/posthog-js#5312).

Prototype both paths with real React Email templates (the MIT demo templates in resend/react-email, for example a welcome, a password reset, and one with a list or conditional), throwaway:

  • Render once. Render the component with Liquid placeholder props ({{ variables.name }}), upload it as a code-managed template, and send by key with data. Show the stored HTML, the UI preview, the content hash stability, and where it breaks: conditionals, loops, .map() over props, number and date formatting, props used in attributes or URLs.
  • Render per send. Render with real props in the SDK on each send and send the resulting HTML. Show what the developer writes, what PostHog stores and shows, and what is lost (template library entry, preview, edit with your agent, content hash).
  • The developer's call site for each path, next to Resend's resend.emails.send({ react: <Welcome name="Ada" /> }).

The one question it answers for Michael: which path, or which mix, should posthog-node ship?

Branch prototype/react-email-templates.

Input from #522: Resend renders react in the SDK on every send (optional peer @react-email/render) and sends HTML only. Render-once into {{ variables.* }} works only for substitution-only emails: quotes inside Liquid get HTML-escaped, and JS conditionals, loops and formatting run at render time. Postmark, Plunk, useSend and Loops all render at send time. So also prototype a hybrid: render per send in the SDK, while the component still shows in the template library (for example rendered once with preview props, keyed by component, with its source path), and show what that costs on the send route (inline HTML next to template).

Activity

  1. Silthus commented on Oct 20, 2026

    @Silthus
    OwnerAuthor

    Runtime: T3 Code
    Thread: thread:project:f66f06fc-0bc1-4bfe-b00a-4aab7f54eb13:58b6f78b-23ee-4861-92f7-3d0caa39b6f5
    Title: prototype #528: React Email templates, render once or per send
    Cast: Opus 5.5 high. AFK cap: 90 minutes.

  2. self-assigned this
    on Oct 20, 2026
  3. Silthus commented on Oct 20, 2026

    @Silthus
    OwnerAuthor

    Claimed. Building the prototype on branch prototype/react-email-templates: a small Bun project under prototype/react-email-templates/ that renders React Email demo templates through render once (Liquid placeholders), render per send, and a hybrid, with each call site next to Resend's and a side-by-side comparison page.

  4. Silthus commented on Oct 20, 2026

    @Silthus
    OwnerAuthor

    Parked: prototype ready for review

    Branch: prototype/react-email-templates (74196f7e49a), throwaway.

    What I built

    A small Bun project. It sends five React Email demo templates (MIT, from resend/react-email: Koala welcome, Dropbox password reset, Vercel invite, Yelp login alert, Stack Overflow tips) and one probe template through three posthog-node send sketches. The fake PostHog behind them runs in memory, but it copies PostHog's real Liquid settings (outputEscape, entity decoding inside {{ }}) and posthog-node's real content hash. All email output is real React Email output.
    The page shows each email four times side by side (Resend's render(react) as the reference, then A, B and C), what PostHog stores in the template library for each path, each call site next to Resend's, and the request log.

    How to open it

    git fetch origin prototype/react-email-templates && git switch prototype/react-email-templates
    cd prototype/react-email-templates && bun install && bun run start   # http://localhost:4528

    out/comparison.html is the same page as one file and opens from disk. bun run optional-prop runs the optional-prop sequence below.

    Call sites

    Call site
    Resend resend.emails.send({ from, to, subject, react: <Welcome name="Ada" /> })
    A. Render once posthog.emails.send({ from, to, template: posthog.emails.reactTemplate(Welcome, { key: 'welcome', subject }), data: { name: 'Ada' } })
    B. Per send posthog.emails.send({ from, to, subject, react: <Welcome name="Ada" /> })
    C. Hybrid posthog.emails.send({ from, to, subject, react: <Welcome name="Ada" />, template: 'welcome' })

    Findings

    Template A. Render once B. Per send C. Hybrid
    Koala welcome Same as Resend Same Same
    Dropbox reset (URL with & in href) Same as Resend Same Same
    Vercel invite (props in src, alt, mailto:) Same text, preheader padding 20 characters short Same Same
    Yelp alert (Intl.DateTimeFormat on a Date) Throws at render Same Same
    Stack Overflow tips (tips.map) Throws at render Same Same
    Probe (plan === 'pro', optional coupon) Sends the wrong email, no error Same Same

    A. Render once

    • Pure substitution works. Each send posts 170 to 460 bytes, the hash is stable across renders, and the result is a real Liquid template that a workflow could also send.
    • PostHog already handles the escaping that Research what a switch from Resend needs from posthog-node and a skill #522 flagged: it decodes entities inside {{ }} before Liquid runs, and it escapes output.
    • A Date or array prop throws at render. That failure is loud.
    • A conditional fails silently: the stored template keeps the branch that the placeholder took, so a Pro customer gets the free-plan text.
    • An optional prop makes the stored template depend on the call. A send without coupon replaces the template, and later sends with a coupon lose the line. The only signal is a template_superseded warning.
    • <Preview> pads the preheader based on the placeholder's length, so after the fill body text can show in the inbox preview. This is cosmetic and fixable.
    • The automatic mix (check "substitution only", then A, otherwise C) passes the probe and sends the wrong email. It proves equality for the PreviewProps only, so it is not safe as a default.

    B. Per send

    • Same as Resend for all six templates. The call site is Resend's.
    • The send route needs inline subject/html/text, which reverses the template-only send of the 2026-10-16 re-chart. Each send posts the full HTML, 4 to 11 KB in these templates.
    • Inline HTML gets no Liquid pass, so a {{ in a person's data stays literal.
    • B loses the library entry, preview, "Managed by code", edit with your agent, content hash, and per-template stats.

    C. Hybrid

    • Same as Resend for all six templates. The call site is B plus template: 'welcome'.
    • The library entry comes from the PreviewProps render with its source path. The hash changes only when the component changes, and uploads use the existing 409, upload, retry once loop.
    • Costs:
      • The send route takes inline HTML next to template, about 120 bytes more per send than B.
      • The preview shows sample data, not each email.
      • The entry has no Liquid, so a send by key alone must be refused.
      • The key must be explicit, because minifiers rename components.
      • subject must be explicit, because <Preview> writes a <title> that posthog-node's HTML-file parser would take as the subject.

    My read: ship C as the only React path, react plus an optional template. Without template it is B. Don't ship A for React, and keep Liquid for HTML template files. A could come back later as an explicit opt-in that refuses Date and array props, never as an automatic pick. Smallest PR order: inline content on the send route plus react/html in posthog-node (B), then template next to inline content (C).

    The question

    Which path, or which mix, should posthog-node ship?

  5. Silthus commented on Oct 20, 2026

    @Silthus
    OwnerAuthor

    Handoff

    Decision: posthog-node ships the hybrid (C) for React Email templates, in two PRs:

    1. Send rendered HTML inline, with a required template key recorded on each send. posthog-node renders with its own renderer on react-dom/server, with no Resend package.
    2. Upload the PreviewProps render as a "Managed by code" template and link each send to it. No call site changes.

    Render once with Liquid placeholders (A) does not ship for React. Liquid stays for HTML template files.

    Branch: prototype/react-email-templates at ebd8ae65c10, in prototype/react-email-templates/. It is throwaway and never merges.

    What carries over

    • src/own-render.tsx: a 30-line renderer, byte for byte the same as React Email's render for all six templates (bun run own-render). react-dom is an optional peer dependency.
    • src/sdk-sketch.tsx, sendHybrid: the 409 template_outdated, upload, retry once flow for the preview render.
    • A React send needs an explicit subject. <Preview> writes a <title>, and posthog-node reads the subject of an HTML file from <title>.
    • The key must be explicit. A minifier renames components, so the SDK can't derive it.
    • A preview-render template has no Liquid. The route must refuse a send by key alone for it.

    Open for PR 1

    • Where the plain-text part comes from: html-to-text (MIT) in the SDK, or conversion on PostHog's side.
    • Whether an inline html send that is not React needs a key too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions