How to Send Emails in Next.js (App Router, 2026)

Most "how to send email in Next.js" tutorials stop at nodemailer.sendMail(). That's fine for a contact form. It's not fine when you need to send welcome emails, password resets, payment receipts, and onboarding sequences to real users.
This guide covers the full picture: picking a provider, building HTML email templates with React Email, sending from API routes and server actions, handling errors, and scaling to production. All code examples use the Next.js App Router with TypeScript.
Pick an Email Provider
You have three solid options. Each code example below lets you switch between them.
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one SDK. If you're building a SaaS product, this is the simplest path because you won't need to glue together three different tools later. It also has built-in retries and native Stripe integration.
- Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
- SendGrid is the enterprise standard. Feature-rich, sometimes complex. Good if you need high volume and don't mind a bigger API surface.
Install Your SDK
npm install sequenzynpm install resendnpm install @sendgrid/mailAdd your API key to .env.local:
SEQUENZY_API_KEY=sq_your_api_key_hereRESEND_API_KEY=re_your_api_key_hereSENDGRID_API_KEY=SG.your_api_key_hereInitialize the Client
Create a shared email client so you're not re-instantiating on every request.
import Sequenzy from "sequenzy";
// Reads SEQUENZY_API_KEY from env automatically
export const sequenzy = new Sequenzy();import { Resend } from "resend";
export const resend = new Resend(process.env.RESEND_API_KEY);import sgMail from "@sendgrid/mail";
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
export { sgMail };Send Your First Email
The simplest possible email. Plain text, no template, just getting something out the door.
import { NextResponse } from "next/server";
import { sequenzy } from "@/lib/email";
export async function POST() {
const result = await sequenzy.transactional.send({
to: "user@example.com",
subject: "Hello from Next.js",
body: "<p>Your app is sending emails. Nice.</p>",
});
return NextResponse.json({ jobId: result.jobId });
}import { NextResponse } from "next/server";
import { resend } from "@/lib/email";
export async function POST() {
const { data, error } = await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
to: "user@example.com",
subject: "Hello from Next.js",
html: "<p>Your app is sending emails. Nice.</p>",
});
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ id: data?.id });
}import { NextResponse } from "next/server";
import { sgMail } from "@/lib/email";
export async function POST() {
try {
await sgMail.send({
to: "user@example.com",
from: "noreply@yourdomain.com",
subject: "Hello from Next.js",
html: "<p>Your app is sending emails. Nice.</p>",
});
return NextResponse.json({ success: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to send";
return NextResponse.json({ error: message }, { status: 500 });
}
}Hit it with curl to test:
curl -X POST http://localhost:3000/api/sendBuild Email Templates with React Email
Raw HTML strings get messy fast. React Email lets you build email templates as React components that compile to email-safe HTML with inline styles.
npm install @react-email/componentsHere's a welcome email template:
// emails/welcome.tsx
import {
Body,
Container,
Head,
Heading,
Html,
Link,
Preview,
Text,
} from "@react-email/components";
interface WelcomeEmailProps {
name: string;
loginUrl: string;
}
export default function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to the team, {name}</Preview>
<Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
<Container
style={{
backgroundColor: "#ffffff",
padding: "40px",
borderRadius: "8px",
margin: "40px auto",
maxWidth: "480px",
}}
>
<Heading style={{ fontSize: "24px", marginBottom: "16px" }}>
Welcome, {name}
</Heading>
<Text style={{ fontSize: "16px", lineHeight: "1.6", color: "#374151" }}>
Your account is ready. You can log in and start exploring.
</Text>
<Link
href={loginUrl}
style={{
display: "inline-block",
backgroundColor: "#f97316",
color: "#ffffff",
padding: "12px 24px",
borderRadius: "6px",
textDecoration: "none",
fontSize: "14px",
fontWeight: "600",
marginTop: "16px",
}}
>
Go to Dashboard
</Link>
</Container>
</Body>
</Html>
);
}Then render and send it:
import { render } from "@react-email/components";
import { NextResponse } from "next/server";
import { sequenzy } from "@/lib/email";
import WelcomeEmail from "@/emails/welcome";
export async function POST(req: Request) {
const { name, email } = await req.json();
const html = await render(WelcomeEmail({ name, loginUrl: "https://app.yoursite.com" }));
const result = await sequenzy.transactional.send({
to: email,
subject: `Welcome, ${name}`,
body: html,
});
return NextResponse.json({ jobId: result.jobId });
}import { render } from "@react-email/components";
import { NextResponse } from "next/server";
import { resend } from "@/lib/email";
import WelcomeEmail from "@/emails/welcome";
export async function POST(req: Request) {
const { name, email } = await req.json();
const html = await render(WelcomeEmail({ name, loginUrl: "https://app.yoursite.com" }));
const { error } = await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
to: email,
subject: `Welcome, ${name}`,
html,
});
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ sent: true });
}import { render } from "@react-email/components";
import { NextResponse } from "next/server";
import { sgMail } from "@/lib/email";
import WelcomeEmail from "@/emails/welcome";
export async function POST(req: Request) {
const { name, email } = await req.json();
const html = await render(WelcomeEmail({ name, loginUrl: "https://app.yoursite.com" }));
try {
await sgMail.send({
to: email,
from: "noreply@yourdomain.com",
subject: `Welcome, ${name}`,
html,
});
return NextResponse.json({ sent: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Send failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}More React Email Components
React Email has components for most things you need in emails:
import {
Button, // CTA buttons with proper email padding tricks
Img, // Images with alt text and sizing
Section, // Layout sections
Row, // Horizontal rows
Column, // Column layout
Hr, // Horizontal rules
CodeBlock, // Formatted code (for developer-facing emails)
Tailwind, // Use Tailwind classes instead of inline styles
} from "@react-email/components";The Tailwind wrapper is especially useful. It lets you write Tailwind classes that get compiled to inline styles:
import { Html, Tailwind, Text, Button } from "@react-email/components";
export function ReceiptEmail({ amount }: { amount: string }) {
return (
<Html>
<Tailwind>
<Text className="text-2xl font-bold text-gray-900">
Payment received
</Text>
<Text className="text-gray-600">
We received your payment of {amount}.
</Text>
<Button
href="https://app.yoursite.com/billing"
className="bg-orange-500 text-white px-6 py-3 rounded-md"
>
View Invoice
</Button>
</Tailwind>
</Html>
);
}You can preview your templates locally with npx react-email dev which spins up a browser preview at localhost:3000.
Send from Server Actions
If you prefer server actions over API routes, that works too. Server actions run on the server, so your API keys stay safe.
"use server";
import { sequenzy } from "@/lib/email";
export async function sendContactEmail(formData: FormData) {
const email = formData.get("email") as string;
const message = formData.get("message") as string;
if (!email || !message) {
return { error: "Email and message are required" };
}
try {
await sequenzy.transactional.send({
to: "you@yourcompany.com",
subject: `Contact form: ${email}`,
body: `<p><strong>From:</strong> ${email}</p><p>${message}</p>`,
});
return { success: true };
} catch {
return { error: "Failed to send message" };
}
}"use server";
import { resend } from "@/lib/email";
export async function sendContactEmail(formData: FormData) {
const email = formData.get("email") as string;
const message = formData.get("message") as string;
if (!email || !message) {
return { error: "Email and message are required" };
}
const { error } = await resend.emails.send({
from: "Contact Form <noreply@yourdomain.com>",
to: "you@yourcompany.com",
subject: `Contact form: ${email}`,
html: `<p><strong>From:</strong> ${email}</p><p>${message}</p>`,
});
if (error) {
return { error: "Failed to send message" };
}
return { success: true };
}"use server";
import { sgMail } from "@/lib/email";
export async function sendContactEmail(formData: FormData) {
const email = formData.get("email") as string;
const message = formData.get("message") as string;
if (!email || !message) {
return { error: "Email and message are required" };
}
try {
await sgMail.send({
to: "you@yourcompany.com",
from: "noreply@yourdomain.com",
subject: `Contact form: ${email}`,
html: `<p><strong>From:</strong> ${email}</p><p>${message}</p>`,
});
return { success: true };
} catch {
return { error: "Failed to send message" };
}
}Use it in a client component:
// app/contact/page.tsx
"use client";
import { sendContactEmail } from "@/app/actions/send-email";
import { useActionState } from "react";
export default function ContactPage() {
const [state, action, pending] = useActionState(sendContactEmail, null);
return (
<form action={action}>
<input name="email" type="email" placeholder="Your email" required />
<textarea name="message" placeholder="Your message" required />
<button type="submit" disabled={pending}>
{pending ? "Sending..." : "Send"}
</button>
{state?.error && <p style={{ color: "red" }}>{state.error}</p>}
{state?.success && <p style={{ color: "green" }}>Message sent!</p>}
</form>
);
}Common Email Patterns for SaaS
Here are the emails almost every SaaS app needs, with production-ready implementations.
Password Reset
import { sequenzy } from "@/lib/email";
export async function sendPasswordResetEmail(email: string, resetToken: string) {
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${resetToken}`;
await sequenzy.transactional.send({
to: email,
subject: "Reset your password",
body: `
<h2>Password Reset</h2>
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
<a href="${resetUrl}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
If you didn't request this, ignore this email.
</p>
`,
});
}import { resend } from "@/lib/email";
export async function sendPasswordResetEmail(email: string, resetToken: string) {
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${resetToken}`;
await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
to: email,
subject: "Reset your password",
html: `
<h2>Password Reset</h2>
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
<a href="${resetUrl}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
If you didn't request this, ignore this email.
</p>
`,
});
}import { sgMail } from "@/lib/email";
export async function sendPasswordResetEmail(email: string, resetToken: string) {
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${resetToken}`;
await sgMail.send({
to: email,
from: "noreply@yourdomain.com",
subject: "Reset your password",
html: `
<h2>Password Reset</h2>
<p>Click the link below to reset your password. This link expires in 1 hour.</p>
<a href="${resetUrl}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
If you didn't request this, ignore this email.
</p>
`,
});
}Payment Receipt
import { sequenzy } from "@/lib/email";
interface ReceiptParams {
email: string;
amount: number; // in cents
plan: string;
invoiceUrl: string;
}
export async function sendReceiptEmail({ email, amount, plan, invoiceUrl }: ReceiptParams) {
const formatted = `$${(amount / 100).toFixed(2)}`;
await sequenzy.transactional.send({
to: email,
subject: `Payment receipt - ${formatted}`,
body: `
<h2>Payment Received</h2>
<p>Thanks for your payment. Here's your receipt:</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">${plan}</td>
</tr>
<tr>
<td style="padding:8px;font-weight:600;">Total</td>
<td style="padding:8px;text-align:right;font-weight:600;">${formatted}</td>
</tr>
</table>
<a href="${invoiceUrl}" style="color:#f97316;">View full invoice</a>
`,
});
}import { resend } from "@/lib/email";
interface ReceiptParams {
email: string;
amount: number;
plan: string;
invoiceUrl: string;
}
export async function sendReceiptEmail({ email, amount, plan, invoiceUrl }: ReceiptParams) {
const formatted = `$${(amount / 100).toFixed(2)}`;
await resend.emails.send({
from: "Your App <billing@yourdomain.com>",
to: email,
subject: `Payment receipt - ${formatted}`,
html: `
<h2>Payment Received</h2>
<p>Thanks for your payment. Here's your receipt:</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">${plan}</td>
</tr>
<tr>
<td style="padding:8px;font-weight:600;">Total</td>
<td style="padding:8px;text-align:right;font-weight:600;">${formatted}</td>
</tr>
</table>
<a href="${invoiceUrl}" style="color:#f97316;">View full invoice</a>
`,
});
}import { sgMail } from "@/lib/email";
interface ReceiptParams {
email: string;
amount: number;
plan: string;
invoiceUrl: string;
}
export async function sendReceiptEmail({ email, amount, plan, invoiceUrl }: ReceiptParams) {
const formatted = `$${(amount / 100).toFixed(2)}`;
await sgMail.send({
to: email,
from: "billing@yourdomain.com",
subject: `Payment receipt - ${formatted}`,
html: `
<h2>Payment Received</h2>
<p>Thanks for your payment. Here's your receipt:</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">${plan}</td>
</tr>
<tr>
<td style="padding:8px;font-weight:600;">Total</td>
<td style="padding:8px;text-align:right;font-weight:600;">${formatted}</td>
</tr>
</table>
<a href="${invoiceUrl}" style="color:#f97316;">View full invoice</a>
`,
});
}Stripe Webhook Handler
If your Next.js app uses Stripe, you'll want to send emails when payments come through:
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sequenzy } from "@/lib/email";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
// Send receipt
await sequenzy.transactional.send({
to: session.customer_email!,
subject: "Payment confirmed",
body: "<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>",
});
// Add them as a subscriber with tags for marketing
await sequenzy.subscribers.create({
email: session.customer_email!,
tags: ["customer", "stripe"],
});
}
return NextResponse.json({ received: true });
}import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { resend } from "@/lib/email";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
to: session.customer_email!,
subject: "Payment confirmed",
html: "<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>",
});
}
return NextResponse.json({ received: true });
}import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sgMail } from "@/lib/email";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
await sgMail.send({
to: session.customer_email!,
from: "noreply@yourdomain.com",
subject: "Payment confirmed",
html: "<h1>Thanks for your purchase!</h1><p>Your subscription is now active.</p>",
});
}
return NextResponse.json({ received: true });
}Error Handling
Emails fail. Networks time out. Rate limits hit. Here's how to handle it.
The Sequenzy SDK has built-in retries (2 retries with exponential backoff by default), so you mostly just need to catch errors. For Resend and SendGrid, you'll want to add retry logic yourself.
import Sequenzy from "sequenzy";
import { sequenzy } from "@/lib/email";
export async function sendEmailSafe(params: {
to: string;
subject: string;
body: string;
}) {
try {
return await sequenzy.transactional.send(params);
} catch (err) {
if (err instanceof Sequenzy.RateLimitError) {
console.error("Rate limited, try again later");
} else if (err instanceof Sequenzy.AuthenticationError) {
console.error("Bad API key");
} else {
console.error("Email send failed:", err);
}
throw err;
}
}import { resend } from "@/lib/email";
export async function sendEmailSafe(params: {
to: string;
subject: string;
html: string;
}) {
const { data, error } = await resend.emails.send({
from: "Your App <noreply@yourdomain.com>",
...params,
});
if (error) {
console.error("Email send failed:", { to: params.to, error: error.message });
throw new Error(error.message);
}
return data;
}import { sgMail } from "@/lib/email";
export async function sendEmailSafe(params: {
to: string;
subject: string;
html: string;
}) {
try {
const [response] = await sgMail.send({
from: "noreply@yourdomain.com",
...params,
});
return { id: response.headers["x-message-id"] };
} catch (err: unknown) {
const error = err as { response?: { body?: unknown }; message?: string };
console.error("Email send failed:", {
to: params.to,
error: error.response?.body ?? error.message,
});
throw new Error("Email send failed");
}
}For critical emails (password resets, receipts), add a retry wrapper:
// lib/retry.ts
export async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;
const delay = Math.pow(2, attempt) * 1000;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error("Unreachable");
}
// Usage (Sequenzy already retries, so this is mainly for Resend/SendGrid)
await withRetry(() => sendEmailSafe({ to, subject, html }));Going to Production
Before you start sending real emails, you need to handle a few things.
1. Verify Your Domain
Every email provider requires domain verification. This means adding DNS records (SPF, DKIM, and usually DMARC) to prove you own the domain you're sending from.
Without this, your emails go straight to spam. No exceptions.
Each provider has a dashboard where you add your domain and get the DNS records to configure. Do this first.
2. Use a Dedicated Sending Domain
Send from something like mail.yourapp.com instead of your root domain. If your email reputation takes a hit, your main domain stays clean.
3. Set a Consistent "From" Address
Pick a from address and stick with it. Changing it frequently confuses spam filters.
// Good: one consistent sender
const FROM = "YourApp <notifications@mail.yourapp.com>";
// Bad: different sender for every email type
// "noreply@yourapp.com" for some
// "team@yourapp.com" for others4. Rate Limit Your Sends
Add basic rate limiting to form-facing endpoints so a bug (or bot) doesn't blast thousands of emails:
// lib/rate-limit.ts
const emailsSent = new Map<string, number[]>();
export function canSendEmail(to: string, maxPerHour = 10): boolean {
const now = Date.now();
const hourAgo = now - 60 * 60 * 1000;
const timestamps = (emailsSent.get(to) ?? []).filter((t) => t > hourAgo);
if (timestamps.length >= maxPerHour) return false;
timestamps.push(now);
emailsSent.set(to, timestamps);
return true;
}Beyond Transactional: Marketing Emails and Sequences
At some point, you'll want more than one-off transactional emails. You'll want to:
- Send onboarding sequences that guide new users through your product over several days
- Run marketing campaigns to announce features or share updates
- Automate lifecycle emails based on what users do (or don't do) in your app
- Track engagement to see which emails get opened and clicked
Most teams stitch together a transactional provider (Resend, SendGrid) with a separate marketing tool (Mailchimp, ConvertKit). That means two dashboards, two billing systems, and keeping subscriber lists in sync.
Sequenzy handles both from one platform. Same SDK, same dashboard. You get transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration for SaaS-specific automations like trial conversion and churn prevention.
Here's what subscriber management looks like with the same SDK:
import { sequenzy } from "@/lib/email";
// Add a subscriber when they sign up
await sequenzy.subscribers.create({
email: "user@example.com",
firstName: "Jane",
tags: ["signed-up"],
customAttributes: { plan: "free", source: "organic" },
});
// Tag them when they upgrade
await sequenzy.subscribers.tags.add({
email: "user@example.com",
tag: "customer",
});
// Track events to trigger automated sequences
await sequenzy.subscribers.events.trigger({
email: "user@example.com",
event: "onboarding.completed",
properties: { completedSteps: 5 },
});You set up sequences in the Sequenzy dashboard (onboarding drip, trial conversion, churn prevention), and the SDK triggers them based on what happens in your app. No webhook glue code needed.
Wrapping Up
Here's what we covered:
- Set up an email client with Sequenzy, Resend, or SendGrid
- Send from API routes for webhook-triggered or backend emails
- Send from server actions for form submissions
- Build templates with React Email for maintainable HTML emails
- Handle errors with proper error types and retry logic
- Production checklist: domain verification, dedicated sending domain, rate limiting
The code in this guide is production-ready. Copy the patterns that fit your app, swap in your provider of choice, and start shipping emails.