https://github.com/heysupertape/react-email-rails
Build and send emails using React and Rails
https://github.com/heysupertape/react-email-rails
action-mailer email email-templates rails react react-email ruby-on-rails
Last synced: about 1 month ago
JSON representation
Build and send emails using React and Rails
- Host: GitHub
- URL: https://github.com/heysupertape/react-email-rails
- Owner: heysupertape
- License: mit
- Created: 2026-05-29T21:22:00.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-06-09T17:50:27.000Z (about 1 month ago)
- Last Synced: 2026-06-09T19:18:11.008Z (about 1 month ago)
- Topics: action-mailer, email, email-templates, rails, react, react-email, ruby-on-rails
- Language: Ruby
- Homepage:
- Size: 553 KB
- Stars: 5
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- License: LICENSE.md
- Security: SECURITY.md
Awesome Lists containing this project
README

# react-email-rails
Build and send emails using React and Rails with [React Email](https://react.email) and [Action Mailer](https://guides.rubyonrails.org/action_mailer_basics.html).
## Contents
- [Why](#why)
- [How](#how)
- [Status](#status)
- [Requirements](#requirements)
- [Quick Start](#quick-start)
- [Usage](#usage)
- [Configuration](#configuration)
- [Deployment](#deployment)
- [Development](#development)
- [Contributing](#contributing)
- [Security](#security)
- [License](#license)
## Why
Building HTML emails is painfully archaic. [React Email](https://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 was extracted from [XOXO](https://xoxo.email) which is pre-launch, so it hasn't been battle-tested in high-volume production environments yet. It's tested in CI across the supported Ruby, Rails, Node, and Vite versions, but the API may still change before 1.0. Please [share feedback and report issues](https://github.com/heysupertape/react-email-rails/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/render` 2.x
> We recommend [rails_vite](https://github.com/skryukov/rails_vite/) for Vite with Rails.
## Quick Start
Add the gem:
```ruby
# Gemfile
gem "react-email-rails"
```
### Automatic Install
Run the installer:
```sh
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/dev` renders through Vite on demand.
- `bin/rails assets:precompile` builds the production renderer bundle automatically.
- `bin/rails react_email_rails:build` builds the bundle directly when CI or tests need it.
### Manual Install
Install the npm package and peer dependencies manually:
```sh
npm i react-email-rails @react-email/render @react-email/components react react-dom
```
Update your Vite config to add the plugin:
```ts
// 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:
```sh
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:
```sh
bin/rails generate react_email_rails:email Account welcome --emails-path=app/emails --extension=jsx
```
Edit the generated mailer to pass any necessary props:
```ruby
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:
```tsx
// 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 (
Welcome, {account.name}
)
}
```
> [@react-email/components](https://react.email/docs/components/html) provides primitives like ``, ``, ``, and more.
That's it. It now renders and delivers like any other Action Mailer email.
## Usage
Pass data from your mailer and each top-level key becomes a prop on the component's default export. Our API mirrors [inertia-rails](https://inertia-rails.dev), making the two libraries feel consistent when used together.
```ruby
mail react: { foo: "bar" }, ...
```
```tsx
export default function Email({ foo }: { foo: string }) {
// ...
}
```
### Props
Choose the level of inference you want for your props:
#### Implicit Component, Instance Props
```ruby
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
```ruby
mail(
...
react: {
account: {
name: account.name,
},
},
)
```
#### Explicit Component, Explicit Props
```ruby
mail(
...
react: "accounts/welcome",
props: {
account: {
name: account.name,
},
},
)
```
### Shared Props
Use `react_email_share` to share data with all of a mailer's emails and subclasses, and it'll be automatically merged with any inline props.
```ruby
class MarketingMailer < ApplicationMailer
# Static value
react_email_share app_name: "Acme"
# Lambda value, evaluated lazily in the mailer instance
react_email_share unread_count: -> { current_user&.unread_count }
# Block, evaluated lazily in the mailer instance
react_email_share do
{ brand: { name: "Acme", url: marketing_url } }
end
end
```
Per-mail props win over shared props of the same name, so a mailer can always override what it inherits:
```ruby
mail react: { app_name: "Acme Pro" }, ... # overrides the shared app_name
```
Shared props apply to all three forms above (`react:` hash, `react: "component", props:`, and `react: true`).
#### Conditional Sharing
Pass any `before_action` filter (`only`, `except`, `if`, `unless`) to scope a share to certain actions:
```ruby
react_email_share only: [:welcome, :reactivation] do
{ promotion: current_promotion }
end
react_email_share if: :user_signed_in? do
{ user: { name: current_user.name } }
end
```
You can also share from inside an action, before calling `mail`:
```ruby
def welcome
react_email_share notice: "Thanks for joining!"
mail react: { account: }, to: account.email, subject: "Welcome"
end
```
#### Deep Merging
By default shared props are merged shallowly, so a per-mail prop replaces a shared one of the same name outright. Pass `deep_merge: true` to merge nested hashes instead:
```ruby
react_email_share do
{ settings: { theme: "light", locale: "en" } }
end
# Shallow (default): settings => { theme: "dark" }
mail react: { settings: { theme: "dark" } }, ...
# Deep: settings => { theme: "dark", locale: "en" }
mail react: { settings: { theme: "dark" } }, deep_merge: true, ...
```
Set `config.deep_merge_shared_props = true` to make deep merging the default for every email. (See [Configuration](#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](https://github.com/okuramasafumi/alba) or [ActiveModel::Serializer](https://github.com/rails-api/active_model_serializers).
### Prop Transformation
Prop keys are camelized by default, so `account.plan_name` arrives as `account.planName`. Override `transform_props` in your [configuration](#configuration).
### 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:
```ruby
mail react: "users/welcome", props: { user: }, to:, subject:
```
Or override `component_path_resolver` globally in your [configuration](#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:
```tsx
// 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 (
{children}
)
}
```
```tsx
// app/javascript/emails/account_mailer/welcome.tsx
import { Text } from "@react-email/components"
import { EmailLayout } from "../_components/email_layout"
export default function Welcome() {
return (
Welcome
)
}
```
See [Component Names](#component-names) for how shared `_` files are handled.
### Editor
If you're also using [@react-email/editor](https://react.email/docs/editor) to let users compose emails inside your app, `ReactEmailRails.compose` can render those stored documents on the server.
See [Editor rendering](docs/editor.md) 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` |
| `deep_merge_shared_props` | `false` |
#### 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 |
```ruby
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:
```ruby
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_requests` renders to bound memory growth. Set it to `nil` to disable recycling.
#### Render Options
`render_options` is passed to [@react-email/render](https://react.email/docs/utilities/render). Use `html` and `text` keys for each output. Option keys are camelized before they cross into JavaScript.
```ruby
ReactEmailRails.configure do |config|
config.render_options = {
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:`:
```ruby
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](https://guides.rubyonrails.org/active_support_instrumentation.html). The payload includes `kind`, `component`, and successful HTML size in `html_bytes`:
```ruby
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](#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:
```ts
reactEmailRails({
emails: "app/emails",
})
```
Use multiple extensions:
```ts
reactEmailRails({
emails: {
extension: [".email.tsx", ".email.jsx"],
},
})
```
Component names come from the Vite directory layout (see [Component Names](#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:
```ts
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:
```ts
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:
```sh
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](docs/editor.md) is enabled, the bundle also includes document renderers.
You can run it directly when needed:
```sh
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](docs/editor.md), 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:
```sh
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:
```ruby
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](CONTRIBUTING.md) for local setup, checks, formatting, and release verification.
## Contributing
Bug reports and pull requests are welcome on GitHub. See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
## Security
Please report vulnerabilities privately. See [SECURITY.md](SECURITY.md) for details.
## License
The gem and npm package are available as open source under the terms of the [MIT License](LICENSE.md).