You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Prototype React Email templates: render once with Liquid or render per send #528
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).
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.
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.
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" /> })
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.
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?
Decision: posthog-node ships the hybrid (C) for React Email templates, in two PRs:
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.
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.
Activity
Silthus commentedon Oct 20, 2026
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.
Silthus commentedon Oct 20, 2026
Claimed. Building the prototype on branch
prototype/react-email-templates: a small Bun project underprototype/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.Silthus commentedon Oct 20, 2026
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
out/comparison.htmlis the same page as one file and opens from disk.bun run optional-propruns the optional-prop sequence below.Call sites
resend.emails.send({ from, to, subject, react: <Welcome name="Ada" /> })posthog.emails.send({ from, to, template: posthog.emails.reactTemplate(Welcome, { key: 'welcome', subject }), data: { name: 'Ada' } })posthog.emails.send({ from, to, subject, react: <Welcome name="Ada" /> })posthog.emails.send({ from, to, subject, react: <Welcome name="Ada" />, template: 'welcome' })Findings
&inhref)src,alt,mailto:)Intl.DateTimeFormaton a Date)tips.map)plan === 'pro', optionalcoupon)A. Render once
{{ }}before Liquid runs, and it escapes output.couponreplaces the template, and later sends with a coupon lose the line. The only signal is atemplate_supersededwarning.<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.B. Per send
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.{{in a person's data stays literal.C. Hybrid
template: 'welcome'.template, about 120 bytes more per send than B.subjectmust 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,
reactplus an optionaltemplate. Withouttemplateit 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 plusreact/htmlin posthog-node (B), thentemplatenext to inline content (C).The question
Which path, or which mix, should posthog-node ship?
Silthus commentedon Oct 20, 2026
Handoff
Decision: posthog-node ships the hybrid (C) for React Email templates, in two PRs:
templatekey recorded on each send. posthog-node renders with its own renderer onreact-dom/server, with no Resend package.PreviewPropsrender 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-templatesatebd8ae65c10, inprototype/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'srenderfor all six templates (bun run own-render).react-domis an optional peer dependency.src/sdk-sketch.tsx,sendHybrid: the 409template_outdated, upload, retry once flow for the preview render.subject.<Preview>writes a<title>, and posthog-node reads the subject of an HTML file from<title>.Open for PR 1
html-to-text(MIT) in the SDK, or conversion on PostHog's side.htmlsend that is not React needs a key too.