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.
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
- Local Preview: Run
npx react-email devto launch the preview server athttp://localhost:3000. Inspect compiled output across multiple viewport widths. - CSS Inlining Verification: Open browser DevTools on the preview. Confirm zero external
<link>or unresolved<style>references remain in the rendered HTML. - 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/htmlheaders. - DNS Validation:
Confirm SPF includes your provider IP and DKIM selectors match provider records.dig TXT yourdomain.com # Check SPF record dig TXT _dmarc.yourdomain.com # Check DMARC record
Production Checklist
- Configure
serverExternalPackages: ['@react-email/render']innext.config.jsto prevent module resolution conflicts with webpack. - Verify all
<img>and<a>tags resolve to absolutehttps://URLs. - Implement retry logic with exponential backoff for transient SMTP
4xx/5xxfailures. - 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 prefixedNEXT_PUBLIC_. - All secrets validated at boot with zod;
.env.localis git-ignored and keys live in Vercel/CI secrets. - Every email is
multipart/alternative— bothhtmlandtext(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, orScript; only@react-email/componentsprimitives. - Rendering happens only inside route handlers or
'use server'actions — never in a client component. - Amazon SES
ConfigurationSetNameis set so bounce/complaint events reach your SNS topic. - SendGrid
contentarray liststext/plainbeforetext/htmlper 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.
Related
- React Email Development — component patterns and the rendering pipeline this setup depends on
- MJML vs React Email for transactional systems — decide before committing to the JSX path
- Running local email previews with Mailpit — catch a broken render before it reaches an inbox
- Modern Email Templating Engines — the broader landscape of compile-time email systems
← Back to React Email Development