AOT Compiler for React Email, 25x rendering improvement, 50x smaller bundles
See the codeWhen React Email DX meets compilers.
I like React Email. I just don't want to ship its renderer when the templates could have been compiled ahead of time.
This plugin takes .email.tsx files and turns them into small HTML and plain-text functions. In the current benchmark that comes out to roughly 25× faster rendering and a 50× smaller email bundle. The exact numbers are in BENCHMARK.md, where they can be properly boring and specific.
.email.tsx + runtime props → HTML + plain text
You still write React Email. The production path doesn't need React, React DOM, React Email, Prism, Marked, or this compiler package.
I was working on an app that wasn't built with React. It needed to send one OTP email. That one email pulled the React runtime and the whole React Email rendering path into the server bundle, which felt invasive for a pretty email editor.
I could have written another email framework, but React Email already has good components, good tooling, and an API people know. Replacing it would fix the bundle and create a different problem. So I kept React Email and moved the expensive part to the build instead.
This is especially handy in SvelteKit, Astro, Nuxt, Angular, or anything else that doesn't already need React. It is still useful in React apps too: React may already be shared, but React Email's renderer and the work it does for every email can still disappear.
Application code stays familiar:
@react-email/renderThe compiler is a dev dependency. It doesn't ask the rest of your application to know that it exists.
pnpm add -D react-email-compiler react react-dom react-email @react-email/render
Requirements:
.email.tsx// Welcome.email.tsx
import { Html, Text } from "react-email";
export interface WelcomeEmailProps {
name: string;
}
export function WelcomeEmail({ name }: WelcomeEmailProps) {
return (
<Html lang="en">
<Text>Hello {name}</Text>
</Html>
);
}
The suffix is the compilation boundary. Ordinary .tsx files are not transformed.
// vite.config.ts
import ReactEmailCompiler from "react-email-compiler/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [ReactEmailCompiler()],
});
Place it before framework plugins.
React applications can keep the usual JSX API:
import { render, toPlainText } from "@react-email/render";
import { WelcomeEmail } from "./Welcome.email";
const html = await render(<WelcomeEmail name="Alex" />);
const text = toPlainText(html);
Applications that do not otherwise use React can invoke the component directly and avoid react/jsx-runtime too:
const html = await render(WelcomeEmail({ name: "Alex" }));
The plugin replaces @react-email/render during the build. React applications reuse their existing JSX runtime; non-React applications can keep the complete email path React-free.
Plain-text-only rendering works with either form:
const text = await render(<WelcomeEmail name="Alex" />, {
plainText: true,
});
// vite.config.ts
import ReactEmailCompiler from "react-email-compiler/vite";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [ReactEmailCompiler(), sveltekit()],
});
For a source-exported workspace package, prevent Vite from externalizing it:
export default defineConfig({
plugins: [ReactEmailCompiler(), sveltekit()],
ssr: {
noExternal: ["@acme/email"],
},
});
Package precompilation is optional. Templates can be compiled at the consuming application boundary.
Pass the same React Email Tailwind configuration used by the templates:
ReactEmailCompiler({
tailwindConfig,
});
import { Html, Tailwind, Text } from "react-email";
export function WelcomeEmail({ name }: { name: string }) {
return (
<Tailwind config={tailwindConfig}>
<Html>
<Text className="m-0 text-lg text-blue-600">Hello {name}</Text>
</Html>
</Tailwind>
);
}
Dynamic class props require statically discoverable defaults so the compiler can include their CSS.
import VitePlugin from "react-email-compiler/vite";
import RollupPlugin from "react-email-compiler/rollup";
import RolldownPlugin from "react-email-compiler/rolldown";
import EsbuildPlugin from "react-email-compiler/esbuild";
import WebpackPlugin from "react-email-compiler/webpack";
import RspackPlugin from "react-email-compiler/rspack";
import BunPlugin from "react-email-compiler/bun";
import FarmPlugin from "react-email-compiler/farm";
Vite, esbuild, and Rolldown have direct integration tests. The remaining adapters use Unplugin compatibility and are experimental.
A 500 ms run across 10 representative templates produced:
| Metric | Result |
|---|---|
| Median runtime speedup | 29.1× |
| Median p95 speedup | 26.2× |
| Gzip bundle reduction | 61.3× |
| Raw bundle reduction | 51.5× |
| Bundled module reduction | 6.9× |
| React runtime in compiled output | No |
The corpus includes authentication, receipts, newsletters, conditional alerts, RTL/Unicode, Markdown, CodeBlock, dynamic primitives, and a 100-record loop. Output parity is checked before timing.
AOT work is not free: the parser-heavy corpus built in 1,197 ms versus 492 ms for the React Email reference. A warm compilation session reduced the AOT build to 895 ms. The optimization exchanges build time for smaller bundles and faster rendering.
See BENCHMARK.md for every fixture, methodology, build measurements, and limitations.
.email.tsx
→ parse TypeScript and JSX
→ identify static and dynamic regions
→ evaluate required module exports in an isolated worker
→ render static React Email primitives
→ compile Tailwind, Markdown, and CodeBlock input
→ lower runtime expressions into EmailIR
→ generate HTML and text functions
The bundler replaces the original module in memory. Source files are not rewritten and generated files are not written into the project.
Static structure is rendered once. Props, branches, nested .map() calls, attributes, and escaping remain generated JavaScript.
A shared compilation session caches Tailwind output and primitive shells. Build-time React rendering is serialized because React and React DOM select their development or production implementation from the process environment.
All 19 React Email 6.9.x components are covered:
Body Button CodeBlock CodeInline Column
Container Font Head Heading Hr
Html Img Link Markdown Preview
Row Section Tailwind Text
CodeBlock and Markdown run their parsers during compilation. Their source, themes, and parser options must be statically analyzable. Prism and Marked are not shipped at runtime.
Supported template behavior includes:
.email.tsx components&&, and ??.map() callsThe compiler rejects unsupported constructs instead of loading React at runtime:
cloneElementdangerouslySetInnerHTMLhtmlToTextOptionspretty output from @react-email/rendertoPlainText()Plain text composed from multiple static Markdown or CodeBlock shells inside an otherwise dynamic template can differ in insignificant whitespace. Fully static templates retain exact toPlainText() output.
Module evaluation is enabled by bundler adapters but runs only when an AOT stage needs concrete exports, such as a zero-prop static component.
Top-level side effects execute when a module is evaluated. Keep email modules side-effect free.
Disable evaluation if necessary:
ReactEmailCompiler({
evaluateModule: false,
});
Force runtime export discovery for every compiled module:
ReactEmailCompiler({
discoverExports: true,
});
interface CompilerOptions {
evaluateModule?:
| boolean
| {
cacheDirectory?: string;
timeoutMs?: number;
};
discoverExports?: boolean;
preRenderStaticExports?: boolean;
renderStaticPrimitives?: boolean;
tailwindConfig?: TailwindConfig;
}
This is a hobby project and 0.1.x is alpha. The compiler has a fairly serious test suite, but I would still verify generated output before rolling it across every email in a production system.
Right now it targets React Email 6.9.x. React Email changes, compiler bugs happen, and there are still unsupported React patterns listed above.
pnpm check
pnpm bench
The verification suite includes:
@react-email/rendertoPlainText()Benchmark details are in BENCHMARK.md.
This little compiler obviously stands on other people's work.
GPL-2.0-only
AOT Compiler for React Email, 25x rendering improvement, 50x smaller bundles
See the codeWhen React Email DX meets compilers.
I like React Email. I just don't want to ship its renderer when the templates could have been compiled ahead of time.
This plugin takes .email.tsx files and turns them into small HTML and plain-text functions. In the current benchmark that comes out to roughly 25× faster rendering and a 50× smaller email bundle. The exact numbers are in BENCHMARK.md, where they can be properly boring and specific.
.email.tsx + runtime props → HTML + plain text
You still write React Email. The production path doesn't need React, React DOM, React Email, Prism, Marked, or this compiler package.
I was working on an app that wasn't built with React. It needed to send one OTP email. That one email pulled the React runtime and the whole React Email rendering path into the server bundle, which felt invasive for a pretty email editor.
I could have written another email framework, but React Email already has good components, good tooling, and an API people know. Replacing it would fix the bundle and create a different problem. So I kept React Email and moved the expensive part to the build instead.
This is especially handy in SvelteKit, Astro, Nuxt, Angular, or anything else that doesn't already need React. It is still useful in React apps too: React may already be shared, but React Email's renderer and the work it does for every email can still disappear.
Application code stays familiar:
@react-email/renderThe compiler is a dev dependency. It doesn't ask the rest of your application to know that it exists.
pnpm add -D react-email-compiler react react-dom react-email @react-email/render
Requirements:
.email.tsx// Welcome.email.tsx
import { Html, Text } from "react-email";
export interface WelcomeEmailProps {
name: string;
}
export function WelcomeEmail({ name }: WelcomeEmailProps) {
return (
<Html lang="en">
<Text>Hello {name}</Text>
</Html>
);
}
The suffix is the compilation boundary. Ordinary .tsx files are not transformed.
// vite.config.ts
import ReactEmailCompiler from "react-email-compiler/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [ReactEmailCompiler()],
});
Place it before framework plugins.
React applications can keep the usual JSX API:
import { render, toPlainText } from "@react-email/render";
import { WelcomeEmail } from "./Welcome.email";
const html = await render(<WelcomeEmail name="Alex" />);
const text = toPlainText(html);
Applications that do not otherwise use React can invoke the component directly and avoid react/jsx-runtime too:
const html = await render(WelcomeEmail({ name: "Alex" }));
The plugin replaces @react-email/render during the build. React applications reuse their existing JSX runtime; non-React applications can keep the complete email path React-free.
Plain-text-only rendering works with either form:
const text = await render(<WelcomeEmail name="Alex" />, {
plainText: true,
});
// vite.config.ts
import ReactEmailCompiler from "react-email-compiler/vite";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [ReactEmailCompiler(), sveltekit()],
});
For a source-exported workspace package, prevent Vite from externalizing it:
export default defineConfig({
plugins: [ReactEmailCompiler(), sveltekit()],
ssr: {
noExternal: ["@acme/email"],
},
});
Package precompilation is optional. Templates can be compiled at the consuming application boundary.
Pass the same React Email Tailwind configuration used by the templates:
ReactEmailCompiler({
tailwindConfig,
});
import { Html, Tailwind, Text } from "react-email";
export function WelcomeEmail({ name }: { name: string }) {
return (
<Tailwind config={tailwindConfig}>
<Html>
<Text className="m-0 text-lg text-blue-600">Hello {name}</Text>
</Html>
</Tailwind>
);
}
Dynamic class props require statically discoverable defaults so the compiler can include their CSS.
import VitePlugin from "react-email-compiler/vite";
import RollupPlugin from "react-email-compiler/rollup";
import RolldownPlugin from "react-email-compiler/rolldown";
import EsbuildPlugin from "react-email-compiler/esbuild";
import WebpackPlugin from "react-email-compiler/webpack";
import RspackPlugin from "react-email-compiler/rspack";
import BunPlugin from "react-email-compiler/bun";
import FarmPlugin from "react-email-compiler/farm";
Vite, esbuild, and Rolldown have direct integration tests. The remaining adapters use Unplugin compatibility and are experimental.
A 500 ms run across 10 representative templates produced:
| Metric | Result |
|---|---|
| Median runtime speedup | 29.1× |
| Median p95 speedup | 26.2× |
| Gzip bundle reduction | 61.3× |
| Raw bundle reduction | 51.5× |
| Bundled module reduction | 6.9× |
| React runtime in compiled output | No |
The corpus includes authentication, receipts, newsletters, conditional alerts, RTL/Unicode, Markdown, CodeBlock, dynamic primitives, and a 100-record loop. Output parity is checked before timing.
AOT work is not free: the parser-heavy corpus built in 1,197 ms versus 492 ms for the React Email reference. A warm compilation session reduced the AOT build to 895 ms. The optimization exchanges build time for smaller bundles and faster rendering.
See BENCHMARK.md for every fixture, methodology, build measurements, and limitations.
.email.tsx
→ parse TypeScript and JSX
→ identify static and dynamic regions
→ evaluate required module exports in an isolated worker
→ render static React Email primitives
→ compile Tailwind, Markdown, and CodeBlock input
→ lower runtime expressions into EmailIR
→ generate HTML and text functions
The bundler replaces the original module in memory. Source files are not rewritten and generated files are not written into the project.
Static structure is rendered once. Props, branches, nested .map() calls, attributes, and escaping remain generated JavaScript.
A shared compilation session caches Tailwind output and primitive shells. Build-time React rendering is serialized because React and React DOM select their development or production implementation from the process environment.
All 19 React Email 6.9.x components are covered:
Body Button CodeBlock CodeInline Column
Container Font Head Heading Hr
Html Img Link Markdown Preview
Row Section Tailwind Text
CodeBlock and Markdown run their parsers during compilation. Their source, themes, and parser options must be statically analyzable. Prism and Marked are not shipped at runtime.
Supported template behavior includes:
.email.tsx components&&, and ??.map() callsThe compiler rejects unsupported constructs instead of loading React at runtime:
cloneElementdangerouslySetInnerHTMLhtmlToTextOptionspretty output from @react-email/rendertoPlainText()Plain text composed from multiple static Markdown or CodeBlock shells inside an otherwise dynamic template can differ in insignificant whitespace. Fully static templates retain exact toPlainText() output.
Module evaluation is enabled by bundler adapters but runs only when an AOT stage needs concrete exports, such as a zero-prop static component.
Top-level side effects execute when a module is evaluated. Keep email modules side-effect free.
Disable evaluation if necessary:
ReactEmailCompiler({
evaluateModule: false,
});
Force runtime export discovery for every compiled module:
ReactEmailCompiler({
discoverExports: true,
});
interface CompilerOptions {
evaluateModule?:
| boolean
| {
cacheDirectory?: string;
timeoutMs?: number;
};
discoverExports?: boolean;
preRenderStaticExports?: boolean;
renderStaticPrimitives?: boolean;
tailwindConfig?: TailwindConfig;
}
This is a hobby project and 0.1.x is alpha. The compiler has a fairly serious test suite, but I would still verify generated output before rolling it across every email in a production system.
Right now it targets React Email 6.9.x. React Email changes, compiler bugs happen, and there are still unsupported React patterns listed above.
pnpm check
pnpm bench
The verification suite includes:
@react-email/rendertoPlainText()Benchmark details are in BENCHMARK.md.
This little compiler obviously stands on other people's work.
GPL-2.0-only