Next Starter Logo

Email

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.

  1. Templates: React Email components, one per file (verification.tsx, password-reset.tsx, and so on). Each one wraps the shared EmailLayout (lib/email/layout.tsx). That keeps the logo, fonts, colors, buttons, and footer consistent across every email.
  2. sendEmail: the single send function in lib/email/index.tsx. It does not render anything on its own. Instead, each template has one async getter (getVerificationEmail, getPasswordResetEmail, and so on). A getter renders its component to HTML and returns { html, preview }. You spread that result into sendEmail.

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 }.

GetterTriggerArgs
getVerificationEmailSign-up email verification (OTP)otp
getPasswordResetEmailUser requests a password resetname, resetLink
getPasswordChangedEmailSecurity notice after a password changename
getEmailChangeVerificationEmailEmail-change request (sent to current address)name, newEmail, verificationLink
getDeleteAccountVerificationEmailAccount-deletion requestname, verificationLink
getAccountSetupEmailAdmin manually creates a username, email, tempPassword
getContactFormEmailVisitor submits the contact formname, email, message
getSubscriptionStartedEmailStripe checkout completesname, planName, amount, nextBillingDate, invoiceUrl?
getSubscriptionRenewedEmailSubscription renewal invoice paidname, amount, nextBillingDate, invoiceUrl?
getPaymentFailedEmailStripe payment failsname, 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 email

This 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:

PropPurpose
titleHeading shown above the body
childrenThe body content
previewInbox preview text (also used as plain-text fallback)
buttonsEmailButton[], each { text, link, variant?, hideLinkFallback? }. The first button auto-appends a copyable link unless hideLinkFallback is set
footerTextSmall 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

Create an account at smtp2go.com.
Add and verify your sending domain.
Generate an API key under Sending → API Keys → set as SMTP2GO_API_KEY.
Set a verified sender address as SENDER_EMAIL.

Environment variables

SMTP2GO_API_KEY=api-...                 # From the SMTP2Go dashboard
SENDER_EMAIL=noreply@yourdomain.com     # Must be a verified sender address

Both 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.

On this page