How Next Starter sends transactional email: React Email templates render to HTML and go out through SMTP2Go via one sendEmail function you can preview and swap.
How it works
All transactional email lives in lib/email/. It has two parts.
- Templates: React Email components, one per file (
verification.tsx,password-reset.tsx, and so on). Each one wraps the sharedEmailLayout(lib/email/layout.tsx). That keeps the logo, fonts, colors, buttons, and footer consistent across every email. sendEmail: the single send function inlib/email/index.tsx. It does not render anything on its own. Instead, each template has oneasyncgetter (getVerificationEmail,getPasswordResetEmail, and so on). A getter renders its component to HTML and returns{ html, preview }. You spread that result intosendEmail.
When you call sendEmail, it builds the message with smtp2go-nodejs and delivers it through SMTP2Go. It sends from SENDER_EMAIL, using APP_CONFIG.name as the display name. The plain-text version comes from the preview text, or from toPlainText(html) if there is no preview. It adds a Reply-To header only when you pass replyTo.
Email is sent from server code only: Better Auth hooks (lib/auth.ts) and Server Actions (app/actions/). It never runs on the client. If a send fails, the error is caught, logged with Pino, and returned as { success: false, error } instead of thrown. A failed email never breaks the action that triggered it. A successful send returns { success: true, messageId }.
Spread a getter into sendEmail:
await sendEmail({
to: user.email,
subject: "Verify your email",
...(await getVerificationEmail(otp)),
});Template reference
Every getter lives in lib/email/index.tsx, is async, and resolves to { html, preview }.
| Getter | Trigger | Args |
|---|---|---|
getVerificationEmail | Sign-up email verification (OTP) | otp |
getPasswordResetEmail | User requests a password reset | name, resetLink |
getPasswordChangedEmail | Security notice after a password change | name |
getEmailChangeVerificationEmail | Email-change request (sent to current address) | name, newEmail, verificationLink |
getDeleteAccountVerificationEmail | Account-deletion request | name, verificationLink |
getAccountSetupEmail | Admin manually creates a user | name, email, tempPassword |
getContactFormEmail | Visitor submits the contact form | name, email, message |
getSubscriptionStartedEmail | Stripe checkout completes | name, planName, amount, nextBillingDate, invoiceUrl? |
getSubscriptionRenewedEmail | Subscription renewal invoice paid | name, amount, nextBillingDate, invoiceUrl? |
getPaymentFailedEmail | Stripe payment fails | name, amount, updatePaymentUrl |
The auth and billing emails are wired into Better Auth hooks in lib/auth.ts. getContactFormEmail is called from app/actions/contact.ts, which also sets replyTo to the visitor's address. getAccountSetupEmail is called from app/actions/user.ts.
Preview templates locally
pnpm emailThis runs the React Email dev server (email dev --dir lib/email --port 3001) at http://localhost:3001. Each component sets default props (for example otp = "123456"), so previews render with realistic placeholder data. Edit a template and the preview reloads on its own.
The shared layout
EmailLayout (lib/email/layout.tsx) is the one place to change styling shared by every email. It renders the app icon, a title, your children, an optional list of buttons, a divider, and a footer with the copyright line. Its props:
| Prop | Purpose |
|---|---|
title | Heading shown above the body |
children | The body content |
preview | Inbox preview text (also used as plain-text fallback) |
buttons | EmailButton[], each { text, link, variant?, hideLinkFallback? }. The first button auto-appends a copyable link unless hideLinkFallback is set |
footerText | Small text above the copyright line |
Button variants (primary, secondary, destructive, success, warning, charcoal) take their colors from APP_CONFIG.theme.colors. Change a brand color there and every email button updates with it. Shared body and info-box text styles are exported as emailStyles for reuse inside templates.
What you can change
Edit a template. Open its file in lib/email/ and change the JSX. Reuse emailStyles and the EmailLayout props above. Run pnpm email to preview. You only need to touch the getter if you change the props the component takes.
Add a new template.
Create a component in lib/email/ wrapped in EmailLayout:
// lib/email/welcome.tsx
import { Text } from "react-email";
import { EmailLayout, emailStyles } from "./layout";
export function WelcomeEmail({ name = "there", preview }: { name?: string; preview?: string }) {
return (
<EmailLayout preview={preview} title="Welcome aboard" footerText="Thanks for joining us.">
<Text style={emailStyles.body}>Hi {name}, your account is ready.</Text>
</EmailLayout>
);
}Add an async getter in lib/email/index.tsx that renders the component to { html, preview }. The render helper is already imported there from react-email:
export async function getWelcomeEmail(name: string) {
const preview = "Your account is ready.";
return { html: await render(<WelcomeEmail name={name} preview={preview} />), preview };
}Call it from server code: ...(await getWelcomeEmail(name)) spread into sendEmail.
Swap the transport. Only two spots know about SMTP2Go: the client created once at the top of lib/email/index.tsx (SMTP2GOApi(env.SMTP2GO_API_KEY)) and the send logic inside sendEmail (the .mail() builder that sets to, from, subject, text, and html, then smtp2go.client().consume(...)). To move to another provider (Resend, SES, Postmark, and so on), rewrite those two spots. Send { to, subject, html, plainText, replyTo } through the new SDK and return the same { success, messageId | error } shape. Templates and getters stay the same. Then update the provider keys in lib/validations/env.ts to match. See environment variables.
Disposable email blocking
Sign-ups from known throwaway email providers are rejected before an account is created. The check runs in a Better Auth database hook in lib/auth.ts (databaseHooks.user.create.before), so it cannot be bypassed by calling the API directly. The hook takes the domain from the new user's email, lowercases it, and looks it up in a Set built from lib/email/blocked-domains.json, a plain JSON array of 71,000+ lowercase domains. On a match it throws an error and the user sees "Please use a permanent email address". To change the list, edit the JSON file directly.
SMTP2Go setup
SMTP2GO_API_KEY.SENDER_EMAIL.Environment variables
SMTP2GO_API_KEY=api-... # From the SMTP2Go dashboard
SENDER_EMAIL=noreply@yourdomain.com # Must be a verified sender addressBoth are validated at startup in lib/validations/env.ts, where SENDER_EMAIL must be a valid email address. The app refuses to start if either is missing. See environment variables for the full list.
Billing & Subscriptions
How Next Starter runs Stripe billing and subscriptions via the Better Auth Stripe plugin: plans, checkout, the customer portal, webhooks, and lifecycle emails.
Admin Dashboard
How Next Starter builds the admin dashboard with role-based access control: a role-gated route group plus admin-only user management and R2 file browsing.