Skip to main content

Setting up React Email with Next.js: API Integration & Configuration

Configure React Email in a Next.js project with API route integration, preview server, and transactional email delivery setup.

Next.js render-and-send flow A POST request hits a Next.js route handler, which renders the React email to HTML and forwards it to the ESP for delivery. Route Handler to Inbox POST /api/send JSON payload Route Handler server-side render(<Email/>) HTML String inlined styles ESP Dispatch Resend / SES / SMTP Rendering runs only on the server; no hydration reaches the client bundle. Validate SPF, DKIM, and DMARC before routing production traffic.
A Next.js route handler renders the React email server-side and hands the inlined HTML to the delivery provider.

Architectural Overview & Dependency Setup

When evaluating Modern Email Templating Engines for production workloads, prioritize server-side rendering compatibility and strict type safety. Align component lifecycles with Next.js App Router conventions to eliminate hydration overhead.

Install core dependencies:

npm install @react-email/components @react-email/render react react-dom

Enforce peer dependency versions compatible with Next.js 14+ in package.json. Isolate email templates from client-side bundling. In Next.js 15+ with the App Router, configure server components external packages via the stable top-level serverExternalPackages key in next.config.js:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  serverExternalPackages: ['@react-email/render'],
};
module.exports = nextConfig;

In Next.js 13.x and 14.x, this option was experimental — use experimental.serverComponentsExternalPackages instead.

Configuring Next.js App Router for Server-Side Email Generation

Next.js route handlers execute server-side by default. Create src/app/api/send/route.ts. Use @react-email/render to compile JSX into static HTML strings.

import { NextResponse } from 'next/server';
import { render } from '@react-email/render';
import WelcomeEmail from '@/components/emails/WelcomeEmail';

export async function POST(req: Request) {
  try {
    const { to, name } = await req.json();
    const html = await render(<WelcomeEmail name={name} />, {
      pretty: process.env.NODE_ENV === 'development'
    });

    // Pass compiled HTML to SMTP/Provider (see section below)
    return NextResponse.json({ status: 'queued' }, { status: 200 });
  } catch (error) {
    console.error('[EmailRender] Compilation failed:', error);
    return NextResponse.json({ error: 'Render pipeline failed' }, { status: 500 });
  }
}

This architecture bypasses client-side hydration entirely, guaranteeing consistent DOM output across legacy email clients.

Implementing the Email Template Component

Effective React Email Development relies on strict component isolation and inline styling. Define templates in src/components/emails/. Use @react-email/components primitives exclusively — do not use Next.js Image, Link, or Script components, as these inject client-side JavaScript that breaks in email clients.

import { Html, Head, Body, Container, Section, Text, Link } from '@react-email/components';

interface WelcomeEmailProps {
  name: string;
}

export default function WelcomeEmail({ name }: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Body style={{ fontFamily: 'system-ui, -apple-system, sans-serif', backgroundColor: '#ffffff', margin: '0', padding: '0' }}>
        <Container style={{ maxWidth: '600px', margin: '0 auto', padding: '24px' }}>
          <Text style={{ fontSize: '16px', lineHeight: '1.5', color: '#333333' }}>
            Welcome, {name}.
          </Text>
          <Section style={{ marginTop: '24px' }}>
            <Link href="https://yourdomain.com/verify" style={{ color: '#0055cc', textDecoration: 'underline' }}>
              Verify your account
            </Link>
          </Section>
        </Container>
      </Body>
    </Html>
  );
}

Always use standard HTML <a> and <img> tags with absolute https:// URLs for links and images. Relative URLs break in email clients.

Transactional API Integration & SMTP Routing

Route the compiled HTML payload through a transactional provider. Validate environment variables at startup to prevent runtime configuration drift.

import { z } from 'zod';
import { Resend } from 'resend';

const envSchema = z.object({
  RESEND_API_KEY: z.string().min(1),
});

// Validate at module load time — throws if RESEND_API_KEY is missing
const env = envSchema.parse(process.env);
const resend = new Resend(env.RESEND_API_KEY);

export async function dispatchEmail(to: string, html: string, subject: string) {
  const { data, error } = await resend.emails.send({
    from: 'noreply@yourdomain.com',
    to,
    subject,
    html,
  });

  if (error) {
    throw new Error(`Email delivery failed: ${error.message}`);
  }
  return data;
}

For Nodemailer with a custom SMTP server:

import nodemailer from 'nodemailer';

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: Number(process.env.SMTP_PORT) || 587,
  secure: process.env.SMTP_SECURE === 'true',
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
});

export async function sendViaSmtp(to: string, subject: string, html: string) {
  await transporter.sendMail({ from: 'noreply@yourdomain.com', to, subject, html });
}

Rendering & Dispatching from a Server Action

Route handlers are not the only server-side entry point. App Router server actions let a form submit straight into a function that renders and dispatches the email, with no client-side fetch wiring. Mark the module 'use server', render with @react-email/render, then hand the HTML to the provider. The action runs only on the server, so the React tree never reaches the client bundle and no email markup gets re-hydrated in the browser.

// src/app/actions/send-welcome.ts
'use server';

import { render } from '@react-email/render';
import { Resend } from 'resend';
import WelcomeEmail from '@/components/emails/WelcomeEmail';

const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendWelcome(formData: FormData) {
  const to = String(formData.get('email'));
  const name = String(formData.get('name'));

  // render() runs server-side; output is static, inlined HTML for Gmail/Outlook/Apple Mail.
  const html = await render(<WelcomeEmail name={name} />);
  // Plain-text alternative — Outlook for Windows and corporate gateways favor a text part.
  const text = await render(<WelcomeEmail name={name} />, { plainText: true });

  await resend.emails.send({
    from: 'noreply@yourdomain.com',
    to,
    subject: 'Welcome',
    html,           // Resend accepts a pre-rendered string here
    text,           // multipart/alternative — improves deliverability past spam filters
  });
}
// src/app/signup/page.tsx — the form binds directly to the action, no API route needed
import { sendWelcome } from '@/app/actions/send-welcome';

export default function SignupPage() {
  return (
    <form action={sendWelcome}>
      <input name="name" />
      <input name="email" type="email" />
      <button type="submit">Sign up</button>
    </form>
  );
}

Server actions inherit the same constraint as route handlers: never import the Next.js Image or Link components into the email tree, and keep all rendering inside the 'use server' boundary so secrets stay off the client. The component patterns in React Email Development apply unchanged here.

Dispatching Through Amazon SES and SendGrid

Resend renders JSX directly, but most teams already run on Amazon SES or SendGrid. Both take the pre-rendered HTML string from render(). The contract is identical — render first, dispatch second — only the SDK call changes.

// src/lib/ses.ts — Amazon SES via the v3 SDK
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
import { render } from '@react-email/render';
import WelcomeEmail from '@/components/emails/WelcomeEmail';

const ses = new SESv2Client({ region: process.env.AWS_REGION });

export async function sendViaSes(to: string, name: string) {
  const html = await render(<WelcomeEmail name={name} />);
  const text = await render(<WelcomeEmail name={name} />, { plainText: true });

  await ses.send(new SendEmailCommand({
    FromEmailAddress: 'noreply@yourdomain.com',
    Destination: { ToAddresses: [to] },
    // Amazon SES: SendEmail (simple) vs SendRawEmail (full MIME control for attachments/headers).
    Content: {
      Simple: {
        Subject: { Data: 'Welcome' },
        Body: {
          Html: { Data: html },  // inlined styles render in Gmail, Apple Mail, iOS Mail
          Text: { Data: text },  // text part for Outlook for Windows / corporate filters
        },
      },
    },
    // Amazon SES: ConfigurationSetName routes open/click/bounce events to your SNS topic.
    ConfigurationSetName: 'transactional-events',
  }));
}
// src/lib/sendgrid.ts — SendGrid v3 Mail Send API
import sgMail from '@sendgrid/mail';
import { render } from '@react-email/render';
import WelcomeEmail from '@/components/emails/WelcomeEmail';

sgMail.setApiKey(process.env.SENDGRID_API_KEY!);

export async function sendViaSendgrid(to: string, name: string) {
  const html = await render(<WelcomeEmail name={name} />);
  const text = await render(<WelcomeEmail name={name} />, { plainText: true });

  await sgMail.send({
    to,
    from: 'noreply@yourdomain.com',
    subject: 'Welcome',
    // SendGrid: content array order matters — text/plain MUST precede text/html per RFC 1341.
    content: [
      { type: 'text/plain', value: text },
      { type: 'text/html', value: html },
    ],
    // SendGrid: mailSettings.sandboxMode validates without delivering — use in staging.
    mailSettings: { sandboxMode: { enable: process.env.NODE_ENV !== 'production' } },
  });
}

Whichever provider you pick, the delivery infrastructure reference covers authentication and bounce handling that sits downstream of this dispatch step.

Environment Variables & Secret Handling

The render pipeline itself needs no secrets, but the dispatch step does, and a leaked RESEND_API_KEY or AWS credential is a full account compromise. Two rules keep keys safe in Next.js.

First, never prefix a secret with NEXT_PUBLIC_. That prefix tells Next.js to inline the value into the client bundle, where anyone can read it from the browser. Provider keys must stay unprefixed so they exist only in the server runtime — which is exactly where route handlers and server actions run.

Second, validate keys at startup so a missing or empty value fails the deploy, not the first send. The zod schema shown earlier throws at module load; expand it to cover every provider you use:

// src/lib/env.ts — fail fast at boot, never at the first dispatch
import { z } from 'zod';

const envSchema = z.object({
  RESEND_API_KEY: z.string().min(1),       // server-only; NEVER NEXT_PUBLIC_*
  AWS_REGION: z.string().min(1),
  SENDGRID_API_KEY: z.string().min(1),
});

// Throws on boot if any provider key is missing — surfaces in CI, not in production traffic.
export const env = envSchema.parse(process.env);

In local development, keys live in .env.local (git-ignored). In production, store them as platform secrets — Vercel project environment variables, AWS Parameter Store, or your CI secret store — never committed to the repo. Vercel injects environment variables into the serverless function at runtime, so the same unprefixed names resolve identically across local and deployed builds.

Variant: Plain-Text Alternative & Preview Text

A production transactional message is multipart/alternative: an HTML part and a plain-text part. @react-email/render produces both from the same component, so you never maintain two templates. The text part improves deliverability — corporate gateways and Outlook for Windows downgrade gracefully to it — and it is what some accessibility tooling reads.

import { render } from '@react-email/render';
import WelcomeEmail from '@/components/emails/WelcomeEmail';

// Same component, two outputs — no second template to drift out of sync.
const html = await render(<WelcomeEmail name="Dev" />);
const text = await render(<WelcomeEmail name="Dev" />, { plainText: true }); // strips tags, keeps links

The preview text (the snippet Gmail and Apple Mail show next to the subject in the inbox list) is set with the <Preview> primitive. Place it as the first child inside <Body>; React Email renders it as a hidden, zero-height span so it never appears in the body itself.

import { Html, Head, Preview, Body, Container, Text } from '@react-email/components';

export default function WelcomeEmail({ name }: { name: string }) {
  return (
    <Html>
      <Head />
      {/* Gmail / Apple Mail / iOS Mail show this in the inbox list, not the body */}
      <Preview>Welcome aboard, {name} — verify your account to get started</Preview>
      <Body>
        <Container>
          <Text>Welcome, {name}.</Text>
        </Container>
      </Body>
    </Html>
  );
}

For a draft or staging variant, gate the dispatch behind NODE_ENV and route to a local inbox instead of the live provider, as covered in the testing protocol below.

Debugging Rendering & Delivery Failures

Delivery failures typically originate from malformed HTML, missing inline styles, or DNS misconfigurations.

Testing & Validation Protocol

  1. Local Preview: Run npx react-email dev to launch the preview server at http://localhost:3000. Inspect compiled output across multiple viewport widths.
  2. CSS Inlining Verification: Open browser DevTools on the preview. Confirm zero external <link> or unresolved <style> references remain in the rendered HTML.
  3. Staging Delivery: Route test payloads to Mailpit or a dedicated staging inbox, following the setup in running local email previews with Mailpit. Verify MIME boundaries and Content-Type: text/html headers.
  4. DNS Validation:
    dig TXT yourdomain.com          # Check SPF record
    dig TXT _dmarc.yourdomain.com   # Check DMARC record
    Confirm SPF includes your provider IP and DKIM selectors match provider records.

Production Checklist

  • Configure serverExternalPackages: ['@react-email/render'] in next.config.js to prevent module resolution conflicts with webpack.
  • Verify all <img> and <a> tags resolve to absolute https:// URLs.
  • Implement retry logic with exponential backoff for transient SMTP 4xx/5xx failures.
  • Validate DKIM/SPF/DMARC alignment before routing to production traffic.
  • Test compiled HTML against the 102KB Gmail clipping limit: wc -c < dist/email.html.
  • No provider key (RESEND_API_KEY, SENDGRID_API_KEY, AWS creds) is prefixed NEXT_PUBLIC_.
  • All secrets validated at boot with zod; .env.local is git-ignored and keys live in Vercel/CI secrets.
  • Every email is multipart/alternative — both html and text (render({ plainText: true })) are sent.
  • <Preview> is the first child of <Body> so Gmail/Apple Mail show the right inbox snippet.
  • Email components never import Next.js Image, Link, or Script; only @react-email/components primitives.
  • Rendering happens only inside route handlers or 'use server' actions — never in a client component.
  • Amazon SES ConfigurationSetName is set so bounce/complaint events reach your SNS topic.
  • SendGrid content array lists text/plain before text/html per RFC 1341.

If you are still choosing a templating approach for the wider notification service, the comparison in MJML vs React Email for transactional systems weighs this JSX setup against the XML compiler path.


← Back to React Email Development