react-email-rails

react-email-rails

Build and send emails using React and Rails with React Email and Action Mailer.

Contents

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/render 2.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/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:

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
     = params.fetch(:account)

    mail(
      to: .email,
      subject: "Welcome",
      react: {
        account: {
          name: .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_requests renders to bound memory growth. Set it to nil to 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.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::

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.