
react-email-rails
Build and send emails using React and Rails with React Email and Action Mailer.
Contents
- Why
- How
- Status
- Requirements
- Quick Start
- Usage
- Configuration
- Deployment
- Development
- Contributing
- Security
- License
Why
Building HTML emails is painfully archaic. React Email brings React, Tailwind, and TypeScript to email templates. This gem wires it into Action Mailer so React components deliver as generated HTML and text emails.
How
In development, the gem renders components live through Vite's dev pipeline, so your emails get the same module resolution and transforms as the rest of your frontend.
In production, Rails builds a server-side renderer bundle during assets:precompile using reactEmailRails() in your Vite config for discovery and options.
react-email-rails automatically renders both HTML and plain-text versions from the same component. Delivery, headers, previews, queues, and callbacks all stay normal Action Mailer. If rendering fails, no email is sent and ReactEmailRails::RenderError is raised.
Status
react-email-rails is pre-1.0. It's tested in CI across the supported Ruby, Rails, Node, and Vite versions, but it hasn't been battle-tested in high-volume production environments yet, and the API may still change before 1.0. Please share feedback and report issues.
Requirements
- Ruby >= 3.3
- Action Mailer, Active Support, and Railties >= 7.1 and < 9.0
- Node >= 20.19
- Vite 7 or 8
- React 18 or 19
@react-email/render2.x
We recommend rails_vite for Vite with Rails.
Quick Start
Add the gem:
# Gemfile
gem "react-email-rails"
Automatic Install
Run the installer:
bin/rails generate react_email_rails:install
This creates config/initializers/react_email_rails.rb, installs missing JavaScript dependencies when it can detect your package manager, adds reactEmailRails() to vite.config.*, and creates app/javascript/emails.
The installed setup then follows the normal Rails lifecycle:
bin/rails generate react_email_rails:email ...creates matching mailers and React components.bin/devrenders through Vite on demand.bin/rails assets:precompilebuilds the production renderer bundle automatically.bin/rails react_email_rails:buildbuilds the bundle directly when CI or tests need it.
Manual Install
Install the npm package and peer dependencies manually:
npm i react-email-rails @react-email/render @react-email/components react react-dom
Update your Vite config to add the plugin:
// vite.config.ts
import { defineConfig } from "vite"
import { reactEmailRails } from "react-email-rails"
export default defineConfig({
plugins: [reactEmailRails()],
})
Your First Email
Generate a mailer and React Email component:
bin/rails generate react_email_rails:email Account welcome
The generator follows Rails' mailer generator shape: NAME [method method]. It creates the mailer, matching React components, a mailer preview, and a test. It reads emails.path and emails.extension from reactEmailRails() when available. Pass flags to override them:
bin/rails generate react_email_rails:email Account welcome --emails-path=app/emails --extension=jsx
Edit the generated mailer to pass any necessary props:
class AccountMailer < ApplicationMailer
def welcome
account = params.fetch(:account)
mail(
to: account.email,
subject: "Welcome",
react: {
account: {
name: account.name,
},
},
)
end
end
Edit the generated email component:
// app/javascript/emails/account_mailer/welcome.tsx
import { Body, Container, Html, Text } from "@react-email/components"
type WelcomeProps = {
account: {
name: string
}
}
export default function Welcome({ account }: WelcomeProps) {
return (
<Html>
<Body>
<Container>
<Text>Welcome, {account.name}</Text>
</Container>
</Body>
</Html>
)
}
@react-email/components provides primitives like
<Button>,<Heading>,<Tailwind>, and more.
That's it. It now renders and delivers like any other Action Mailer email.
Usage
Pass data from your mailer. Each top-level key becomes a prop on the component's default export.
mail react: { foo: "bar" }, ...
export default function Email({ foo }: { foo: string }) {
// ...
}
Choose the level of inference you want:
Implicit Component, Instance Props
class AccountMailer < ApplicationMailer
use_react_instance_props
def welcome
@account = params.fetch(:account)
mail react: true, to: @account.email, subject: "Welcome"
end
end
Action Mailer's framework assigns (including params and rendered_format) are excluded from instance props.
Without use_react_instance_props, react: true still infers the component and renders it with no props, which is handy for emails that take no props at all.
Implicit Component, Explicit Props
mail(
...
react: {
account: {
name: account.name,
},
},
)
Explicit Component, Explicit Props
mail(
...
react: "accounts/welcome",
props: {
account: {
name: account.name,
},
},
)
These forms mirror inertia-rails, making the two libraries feel consistent when used together.
Component Names
Component names are inferred from the mailer and action:
| Mailer action | Component |
|---|---|
AccountMailer#welcome |
account_mailer/welcome |
Users::InviteMailer#new_invite |
users/invite_mailer/new_invite |
Rails derives account_mailer from AccountMailer via mailer_name. By default, account_mailer/welcome resolves to app/javascript/emails/account_mailer/welcome.tsx or .jsx.
Files and directories starting with _ are ignored as renderable email entries by default. Use them for shared components such as _components/email_layout.tsx. They can still be imported by email components.
Override the inferred name per mail:
mail react: "users/welcome", props: { user: }, to:, subject:
Or override component_path_resolver globally in your configuration.
Prop Serialization
Like render json:, mail react: accepts any object that responds to as_json, including hashes, Active Model objects, and serializers such as Alba or ActiveModel::Serializer.
Prop Transformation
Prop keys are camelized by default, so account.plan_name arrives as account.planName. Override transform_props in your configuration.
Layouts
Action Mailer layouts aren't applied to react: emails. React Email treats layouts like any other component, so share structure with normal React composition instead:
// app/javascript/emails/_components/email_layout.tsx
import { Body, Container, Html } from "@react-email/components"
import type { ReactNode } from "react"
type EmailLayoutProps = {
children: ReactNode
}
export function EmailLayout({ children }: EmailLayoutProps) {
return (
<Html>
<Body>
<Container>{children}</Container>
</Body>
</Html>
)
}
// app/javascript/emails/account_mailer/welcome.tsx
import { Text } from "@react-email/components"
import { EmailLayout } from "../_components/email_layout"
export default function Welcome() {
return (
<EmailLayout>
<Text>Welcome</Text>
</EmailLayout>
)
}
See Component Names for how shared _ files are handled.
Editor
If you're also using @react-email/editor to let users compose emails inside your app, ReactEmailRails.compose can render those stored documents on the server.
See Editor rendering for setup and usage.
Configuration
Configuration is handled primarily on the Rails side, though there are some Vite options to be aware of.
Rails Configuration
If the defaults don't fit, override them in config/initializers/react_email_rails.rb:
| Option | Default |
|---|---|
component_path_resolver |
->(mailer:, action:) { "#{mailer}/#{action}" } |
transform_props |
:lower_camel |
render_mode |
:subprocess |
render_options |
{} |
render_timeout |
10 seconds |
render_process_max_requests |
1_000 |
on_render_error |
nil |
Prop Transformation
Set transform_props to another supported value if you prefer a different prop key style:
| Value | Example |
|---|---|
:camel |
AccountName |
:lower_camel (default) |
accountName |
:dash |
account-name |
:snake |
account_name |
:none |
preserves serialized keys |
ReactEmailRails.configure do |config|
config.transform_props = :none
end
transform_props only controls prop key names. Props are always serialized with as_json.
Render Modes
:subprocess starts a fresh Node process for each render. It's simple, isolated, and always uses the latest bundle, but pays Node startup and bundle load each time.
:persistent reuses one long-lived Node process per worker. It's faster for render-heavy workers, but uses more memory and can serve a stale component until recycled. The default :subprocess mode is usually enough. Switch when Node startup shows up in traces or batch jobs render many emails from the same bundle.
Enable persistent mode for render-heavy worker processes:
ReactEmailRails.configure do |config|
config.render_mode = :persistent
end
Persistent mode keeps one Node child per process:
- Renders are newline-delimited JSON and processed one at a time. Scale throughput with more worker processes.
- It's fork-safe: under clustered Puma or forking job runners, each worker spawns its own child.
- The child is recycled after
render_process_max_requestsrenders to bound memory growth. Set it tonilto disable recycling.
Render Options
render_options is passed to @react-email/render. Use html and text keys for each output. Option keys are camelized before they cross into JavaScript.
ReactEmailRails.configure do |config|
config. = {
html: {
pretty: Rails.env.development?
},
text: {
html_to_text_options: {
selectors: [{ selector: "img", format: "skip" }],
},
},
}
end
Error Reporting
Use on_render_error to report failures before the exception is re-raised. The callback receives the error plus kind: and component::
ReactEmailRails.configure do |config|
config.on_render_error = ->(error, **context) {
Rails.error.report(error, context:)
}
end
Instrumentation
Every render emits render.react-email-rails through ActiveSupport::Notifications. The payload includes kind, component, and successful HTML size in html_bytes:
ActiveSupport::Notifications.subscribe("render.react-email-rails") do |event|
Rails.logger.info("[react-email-rails] Rendered #{event.payload[:component]} (Duration: #{event.duration.round}ms | Size: #{event.payload[:html_bytes]} bytes)")
end
Vite Configuration
Most apps only need the reactEmailRails() plugin from Quick Start. The options below change component discovery, bundle dependency handling, and isolated renderer transforms.
In development and production, the isolated renderer loads reactEmailRails(), JSX support, and component-facing Vite config such as resolve, define, css, json, assetsInclude, esbuild, and oxc. It doesn't load your other app plugins. Server, preview, dependency optimization, and build output settings stay owned by react-email-rails.
Plugin Options
| Option | Default | Description |
|---|---|---|
emails.path |
"app/javascript/emails" |
Directory containing email components |
emails.extension |
[".tsx", ".jsx"] |
Component extension, or an array of extensions |
emails.ignore |
["**/_*", "**/_*/**"] |
Glob patterns ignored under emails.path |
standalone |
true |
Inline production renderer bundle dependencies |
vite |
{} |
Extra email-only Vite config for compilation and resolution |
Use a custom directory:
reactEmailRails({
emails: "app/emails",
})
Use multiple extensions:
reactEmailRails({
emails: {
extension: [".email.tsx", ".email.jsx"],
},
})
Component names come from the Vite directory layout (see Component Names). To map mailer actions to a different layout, override component_path_resolver on the Ruby side rather than renaming in the plugin, so both halves stay in sync.
Advanced: Email-Only Vite Plugins
Most apps don't need extra email plugins. If email components need a transform that isn't part of Vite's default pipeline, add that transform to the isolated renderer:
import mdx from "@mdx-js/rollup"
import { defineConfig } from "vite"
import { reactEmailRails } from "react-email-rails"
export default defineConfig({
plugins: [
reactEmailRails({
vite: {
plugins: [mdx()],
},
}),
],
})
These vite options are used by react-email-rails-dev and react-email-rails-build. Only assetsInclude, css, define, esbuild, json, oxc, plugins, and resolve are accepted. Output settings such as build.outDir and build.rollupOptions are ignored so Ruby can always find the bundle.
Standalone Builds
By default the production renderer bundle inlines React, @react-email/render, and other Node dependencies. This works well for Rails deploys that build assets in one stage and run without node_modules in the final image. Development previews keep dependencies external, even when standalone is enabled.
Set standalone: false when your runtime already ships node_modules and you prefer a smaller SSR-style bundle:
reactEmailRails({
standalone: false,
})
Externalized bundles are smaller and may build faster, but the renderer needs the externalized packages available at runtime.
Deployment
For production deploys, run the normal Rails asset task:
bin/rails assets:precompile
The react_email_rails:build task is hooked into assets:precompile automatically. It loads reactEmailRails() options from your Vite config, then writes tmp/react-email-rails/emails.js with the email component registry. If Editor document rendering is enabled, the bundle also includes document renderers.
You can run it directly when needed:
bin/rails react_email_rails:build
Production rendering runs that bundle with Node. Set SKIP_REACT_EMAIL_RAILS_BUILD=1 to skip the automatic asset hook. Directly running bin/rails react_email_rails:build always attempts the build.
The npm package, Vite, React, and @react-email/render must be available when Rails runs assets:precompile. If you enable Editor document rendering, its peer dependencies must be available too.
The bundle is required, not an optimization. If it's missing, renders raise ReactEmailRails::RenderError. Action Mailer deliveries aren't sent.
The Ruby gem and npm package must stay on the same version. The renderer includes a small protocol/version handshake, so mismatched installs fail with an actionable ReactEmailRails::RenderError instead of silently returning malformed output.
The build command preserves emails.path, emails.extension, emails.ignore, standalone, and email-only vite options.
Renderer Verification
To confirm the renderer is ready before relying on it, run:
bin/rails react_email_rails:verify
It checks that the render command runs and that the npm package version matches the gem, then exits non-zero with an actionable message on failure. Wire it into CI or release steps to catch missing bundles or version drift before the first render.
For programmatic checks (for example, a health endpoint), ReactEmailRails.healthy? returns a boolean. If you specifically want a check at boot, call it from your own initializer and scope it to the processes that send mail so others don't pay the cost:
Rails.application.config.after_initialize do
if Rails.env.production? && Sidekiq.server? && !ReactEmailRails.healthy?
Rails.logger.error("[react-email-rails] renderer verification failed")
end
end
Development
See CONTRIBUTING.md for local setup, checks, formatting, and release verification.
Contributing
Bug reports and pull requests are welcome on GitHub. See CONTRIBUTING.md before opening a pull request.
Security
Please report vulnerabilities privately. See SECURITY.md for details.
License
The gem and npm package are available as open source under the terms of the MIT License.