How Next Starter sends transactional emails with SMTP2Go and React Email, including configuration, the sendEmail function, built-in templates, disposable email blocking, and adding custom templates.
Overview
Next Starter sends transactional emails through SMTP2Go using React Email for templating. All email sending is centralized in lib/email/index.tsx, which exposes a sendEmail function plus a set of async template helpers (one per template file in lib/email/).
Emails are only sent from server-side code: Server Actions, Better Auth hooks, or API routes. They never touch the client.
You can preview every template locally with pnpm email (runs email dev --dir lib/email --port 3001).
Configuration
The SMTP2Go client is initialized in lib/email/index.tsx using the API key from your environment:
// lib/email/index.tsx
import { render, toPlainText } from "react-email";
import SMTP2GOApi from "smtp2go-nodejs";
import { env } from "@/lib/validations/env";
const smtp2go = SMTP2GOApi(env.SMTP2GO_API_KEY);Sending an Email
The sendEmail function is the single point of contact for all outbound mail. It accepts a recipient, subject, HTML body, and optional preview and replyTo overrides:
interface SendEmailOptions {
to: string;
subject: string;
html: string;
preview?: string;
replyTo?: string;
}
export async function sendEmail({ to, subject, html, preview, replyTo }: SendEmailOptions) {
try {
const plainText = preview || toPlainText(html);
const mailService = smtp2go
.mail()
.to({ email: to })
.from({ email: env.SENDER_EMAIL, name: APP_CONFIG.name })
.subject(subject)
.text(plainText)
.html(html);
if (replyTo) {
mailService.headers({ header: "Reply-To", value: replyTo });
}
const response = await smtp2go.client().consume(mailService);
const emailId = response?.data?.email_id;
if (!emailId) return { success: false, error: "Email send failed" };
return { success: true, messageId: emailId };
} catch (error) {
logger.error(
{ event: "email_send_failure", err: error },
"Failed to send email via SMTP2Go",
);
return { success: false, error };
}
}Errors are caught and logged with Pino. The function does not throw, so a failed email will not break the calling operation. On failure it returns { success: false, error } instead of { success: true, messageId }.
Example: Sending from a Server Action
The built-in contact form action in app/actions/contact.ts is the canonical example. It validates the form data with Zod, optionally checks a reCAPTCHA token, then spreads the React-rendered { html, preview } from getContactFormEmail into sendEmail and sets replyTo to the visitor's address:
"use server";
import { sendEmail, getContactFormEmail } from "@/lib/email";
import { APP_CONFIG } from "@/lib/config";
import { contactSchema } from "@/lib/validations/contact";
export async function submitContactForm(formData: FormData) {
const { name, email, message } = contactSchema.parse({
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
});
const result = await sendEmail({
to: APP_CONFIG.email,
subject: `New contact form submission from ${name}`,
...(await getContactFormEmail(name, email, message)),
replyTo: email,
});
if (!result.success) {
return { success: false, error: "Failed to send message" };
}
return { success: true };
}Email Templates
Templates are React Email components in lib/email/*.tsx. Each template wraps the shared EmailLayout component (lib/email/layout.tsx) so brand colors, fonts, container styling, and the footer stay consistent across messages. Templates are rendered to HTML on demand by an async getter exported from lib/email/index.tsx that returns { html, preview }.
Built-in Templates
| Function | When it is sent |
|---|---|
getVerificationEmail(otp) | On sign-up, sends a 6-digit OTP code to verify the email address |
getPasswordResetEmail(name, resetLink) | When a user requests a password reset |
getEmailChangeVerificationEmail(name, newEmail, link) | When a user requests an email address change, sent to their current address for approval |
getDeleteAccountVerificationEmail(name, link) | When a user requests account deletion |
getAccountSetupEmail(name, email, tempPassword) | When an admin creates a user account manually |
getContactFormEmail(name, email, message) | When a visitor submits the contact form |
getSubscriptionStartedEmail(...) | After a successful Stripe checkout |
getSubscriptionRenewedEmail(...) | After a subscription renewal invoice is paid |
getPaymentFailedEmail(...) | When a Stripe payment fails |
getPasswordChangedEmail(name) | Security notification sent after a successful password change |
Every helper is async and resolves to { html, preview } — spread the result into sendEmail as shown in the contact-form example above.
Adding a New Template
- Create a new React Email component file under
lib/email/, wrapping its content in<EmailLayout>:
// lib/email/welcome.tsx
import { Text } from "react-email";
import { EmailLayout } from "./layout";
export function WelcomeEmail({ name, dashboardUrl }: { name: string; dashboardUrl: string }) {
return (
<EmailLayout
title="Welcome aboard"
preview="Your account is ready"
buttons={[{ text: "Go to Dashboard", link: dashboardUrl, variant: "primary" }]}
footerText="Thanks for joining us."
>
<Text>Hello {name},</Text>
<Text>Your account is ready. Click the button below to get started.</Text>
</EmailLayout>
);
}- Add an
asynchelper tolib/email/index.tsxthat renders the component and returns{ html, preview }:
import { WelcomeEmail } from "./welcome";
export async function getWelcomeEmail(name: string, dashboardUrl: string) {
const html = await render(<WelcomeEmail name={name} dashboardUrl={dashboardUrl} />);
return { html, preview: "Your account is ready" };
}- Import and call it from your server code:
import { sendEmail, getWelcomeEmail } from "@/lib/email";
await sendEmail({
to: user.email,
subject: "Welcome!",
...(await getWelcomeEmail(user.name, `${baseUrl}/dashboard`)),
});Run pnpm email to preview the new template in your browser at http://localhost:3001 before wiring it up.
Disposable Email Blocking
Sign-up attempts from known disposable or temporary email providers are rejected before an account is created. The check lives in app/actions/user.ts:
import blockedDomains from "@/lib/email/blocked-domains.json";
const blockedDomainsSet = new Set(blockedDomains);
export async function isDisposableEmail(email: string): Promise<boolean> {
const domain = email.split("@")[1]?.toLowerCase();
const isBlocked = domain ? blockedDomainsSet.has(domain) : false;
if (isBlocked) {
after(() => {
logger.info(
{ event: "disposable_email_blocked", email, domain },
"Disposable email blocked",
);
});
}
return isBlocked;
}lib/email/blocked-domains.json ships with over 71,000 known disposable domain entries. When a blocked domain is detected, the attempt is logged at info level via Pino and the caller receives true. The registration flow should return an error to the user without revealing that the specific domain is on a blocklist.
To add or remove entries, edit lib/email/blocked-domains.json directly. The file is a plain JSON array of lowercase domain strings.
SMTP2Go Setup
- Create an account at smtp2go.com
- Add and verify your sending domain
- Generate an API key under Sending > API Keys
- Set a verified sender address as
SENDER_EMAIL
Required Environment Variables
SMTP2GO_API_KEY=api-... # From SMTP2Go dashboard
SENDER_EMAIL=noreply@yourdomain.com # Must be a verified sender addressBoth variables are validated at startup in lib/validations/env.ts. The application will refuse to start if either is missing.