feat(react-email): add --esbuild-plugins option for custom template bundling

Merged
ME
23d ago
+604-45

Intent

Feature. Lets users apply their own esbuild plugins when react-email bundles their templates, so templates that depend on a compile-time transform can be previewed and exported. The motivating case is Lingui macros for i18n, requested in discussion #2334 ("Load custom babel/swc/esbuild plugins"): the macro transform runs as an esbuild plugin, but the preview server's esbuild call had a fixed plugin list, so such templates could only be authored with Lingui's more verbose runtime API.

Changes

  • New --esbuild-plugins <path> option on email dev, email build and email export. The path points to a module whose default export is an array of esbuild plugins, or a function returning one (sync or async) for plugins that need setup.
  • dev and build forward the resolved absolute path to the preview app through a new REACT_EMAIL_INTERNAL_ESBUILD_PLUGINS_PATH variable, following how --clients and the other internal variables are passed. The preview app loads the module once per process and passes the plugins to getEmailComponent. export loads the module directly in the CLI with jiti, like the other user-project code the CLI evaluates.
  • renderingUtilitiesExporter (both the CLI and the preview app copies) no longer reads the template's source itself. It now resolves each template entry point to a wrapper module, using an esbuild path suffix so output file names are unchanged, and the wrapper re-exports the template plus render and reactEmailCreateReactElement. Without this, whichever onLoad claimed the entry first won, so the exporter and a user plugin could never both apply to the template file. With the wrapper, the template goes through the regular load pipeline and user plugins can transform it.
  • A changeset (minor for react-email and @react-email/ui) and a short section in the react-email readme.

Not covered: no config-file form of the option (only the CLI flag), and the built-in inlineCssLoader and exporter still run before user plugins, so a user plugin cannot replace those.

Technical notes

The alternative of appending user plugins after the built-in ones would have kept the exporter as is, but the exporter's onLoad returned the entry's contents and esbuild stops at the first onLoad result, so user plugins never saw the template file, which is exactly the file a macro transform needs to see. Rewriting the exporter as an entry wrapper was the smallest change that makes the two compose. The wrapper uses export * from plus import * as and export default emailModule.default rather than export { default } from, so a template without a default export still reaches the existing "does not contain a default exported function" error instead of failing inside esbuild.

Testing

  • New spec for the plugins loader and a new getEmailComponent case that runs a fixture plugin rewriting the template source, asserting the rendered HTML changed. All 108 preview app specs pass, including the existing inline snapshots, which confirms the wrapper produces the same output as before.
  • pnpm --filter @react-email/ui build (Next 16, Turbopack) succeeds, which covers the bundler-ignored dynamic import of the user module.
  • Manually ran email dev --esbuild-plugins ./plugins.mjs against apps/demo with a plugin that stamps the <Preview> text and confirmed the stamp in the served preview, and email export --esbuild-plugins with the same plugin, confirming the stamp in all built templates.

[!NOTE]
AI agent: Claude Code | Model: Claude Fable 5.1 | Subagents: None


Summary by cubic

Adds an --esbuild-plugins <path> option to email dev, email build, and email export so templates that depend on a compile-time transform, such as Lingui's i18n macros, can be previewed and exported.

  • The path points to a module whose default export is an array of esbuild plugins, or a function returning one (sync or async). Both the preview app and export load the module with jiti, so TypeScript plugin modules work on every supported Node version; dev and build forward the resolved path via REACT_EMAIL_INTERNAL_ESBUILD_PLUGINS_PATH.
  • The rendering utilities exporter now resolves each template entry to a wrapper module instead of reading the template source, so user plugins and the exporter can both apply to the template file.
  • A plugin module that fails to load surfaces as a normal error instead of crashing: the preview shows it in the render's error overlay, and export exits with "Failed to build emails". Failed loads aren't cached, so fixing the module takes effect on the next render without a restart.
  • Adds CLI documentation for the option on all three commands and a "Custom esbuild plugins" guide.
  • There is no config-file form of the option, and the built-in inlineCssLoader and exporter still run before user plugins, so a user plugin cannot replace them.