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 wraps the sharedEmailLayoutfromlib/email/layout.tsx, which is what keeps the logo, fonts, colors, buttons, and footer identical across every email. sendEmail: the single send function inlib/email/index.tsx. It renders nothing itself. Every template has oneasyncgetter in that same file (getVerificationEmail,getPasswordResetEmail, and so on) that renders the component to HTML and returns{ html, preview }for you to spread intosendEmail.
sendEmail builds the message with smtp2go-nodejs and hands it to SMTP2Go. It sends from SENDER_EMAIL with APP_CONFIG.name as the display name, and adds a Reply-To header only when you pass replyTo. The plain-text part is toPlainText(html); when a caller passes preview, that string goes in first as a preheader, padded with invisible characters so Gmail's inbox snippet shows the preview alone rather than the body bleeding in after it.
Sending happens in server code only: Better Auth hooks in lib/auth.ts and Server Actions under app/actions/. sendEmail never throws. A failure comes back as { success: false, error }, with any caught exception logged through Pino first, and a success as { success: true, messageId }, so a dead send can't take down the action that triggered it. Callers decide what to do about the result, and most do nothing with it. The contact form is the one that checks and tells the visitor the message didn't go out.
Spread a getter into sendEmail:
await sendEmail({
to: user.email,
subject: "Verify your email",
...(await getVerificationEmail(otp)),
});Template reference
Eleven getters, all exported from lib/email/index.tsx. Their argument lists are in that file and TypeScript enforces them. What the file won't tell you is when each one goes out.
| Getter | Sent when |
|---|---|
getVerificationEmail | Sign-up email verification (OTP) |
getPasswordResetEmail | A user requests a password reset |
getPasswordChangedEmail | Security notice after a password change |
getEmailChangeVerificationEmail | Email-change request, sent to the current address |
getNewEmailConfirmationEmail | Email-change confirmation, sent to the new address once the current one approves |
getDeleteAccountVerificationEmail | Account-deletion request |
getAccountSetupEmail | An admin creates a user with the "Send welcome email" switch on |
getContactFormEmail | A visitor submits the contact form |
getSubscriptionStartedEmail | Stripe checkout completes |
getSubscriptionRenewedEmail | A renewal invoice is paid |
getPaymentFailedEmail | An invoice payment fails |
lib/auth.ts calls the auth getters from its Better Auth hooks and the billing getters from the Stripe plugin's onSubscriptionComplete and onEvent callbacks. getNewEmailConfirmationEmail goes out from emailVerification.sendVerificationEmail, which returns early unless the user is already verified, so only the change-email flow reaches it. getContactFormEmail runs from app/actions/contact.ts, which sends to APP_CONFIG.email and sets replyTo to the visitor's address so you can answer with a plain reply. getAccountSetupEmail runs from app/actions/user.ts.
Preview templates locally
pnpm emailThat starts the React Email dev server (email dev --dir lib/email --port 3001) on localhost:3001. Templates set default props, otp = "123456" in verification.tsx for one, so previews render with placeholder data instead of empty holes.
The shared layout
EmailLayout (lib/email/layout.tsx) is the one file to touch for styling that every email shares. It stacks the app icon (public/icon-512.png, loaded from getBaseUrl()), a title, your children, any buttons, a divider, then footerText above the copyright line.
Two optional props do more than their names let on. preview is the inbox preview line, and the getter passes that same string to sendEmail, which prepends it to the plain-text body. buttons is an EmailButton[] of { text, link, variant?, hideLinkFallback? }; under the first button the layout prints "Or copy this link:" followed by the raw URL, unless hideLinkFallback is set.
Button variants (primary, secondary, destructive, success, warning, charcoal) take their colors from APP_CONFIG.theme.colors. Change a brand color there and every button using that variant follows. 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. The getter only needs a matching edit if you change the component's props or its preview line, since the getter owns the preview string.
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>
);
}
export default WelcomeEmail;Every template in lib/email/ follows this shape. Keep the default export. The preview server only treats a file as an email if it has one.
Import the component into lib/email/index.tsx and add an async getter that renders it to { html, preview }. The render helper is already imported there from react-email:
import { WelcomeEmail } from "./welcome";
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. 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, adds the Reply-To header, then smtp2go.client().consume(...)). Rewrite those two for Resend, SES, Postmark, or whatever you prefer. Keep the same { to, subject, html, preview, replyTo } options going in and the same { success, messageId | error } shape coming back out, and templates and getters carry on untouched. Then swap the provider keys in lib/validations/env.ts.
Disposable email blocking
A Better Auth database hook in lib/auth.ts (databaseHooks.user.create.before) rejects email and password sign-ups from known throwaway providers before the user row is written, so posting straight to the API doesn't get you around it. Better Auth runs that hook on every user creation, but it returns immediately unless the path is /sign-up/email, which is why Google sign-ins and admin-created accounts skip it. The domain comes off the new user's email, lowercased, and gets looked up in a Set built from lib/email/blocked-domains.json, a flat array of 71,000+ lowercase domains. A match throws and the user sees "Please use a permanent email address". Edit the JSON to change the list.
Environment variables
| Variable | Example | Notes |
|---|---|---|
SMTP2GO_API_KEY | api-... | Generated under Sending → API Keys in the SMTP2Go dashboard. |
SENDER_EMAIL | noreply@yourdomain.com | Must be a verified sender address. |
Both are required. Zod validates them in lib/validations/env.ts (SENDER_EMAIL has to parse as an email address) the first time that module is imported, so a missing key throws there rather than at the first send. To get them, create an account at smtp2go.com, add and verify your sending domain, then generate a key. See Environment Variables.
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.