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 wraps the shared EmailLayout from lib/email/layout.tsx, which is what keeps the logo, fonts, colors, buttons, and footer identical across every email.
  2. sendEmail: the single send function in lib/email/index.tsx. It renders nothing itself. Every template has one async getter in that same file (getVerificationEmail, getPasswordResetEmail, and so on) that renders the component to HTML and returns { html, preview } for you to spread into sendEmail.

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.

GetterSent when
getVerificationEmailSign-up email verification (OTP)
getPasswordResetEmailA user requests a password reset
getPasswordChangedEmailSecurity notice after a password change
getEmailChangeVerificationEmailEmail-change request, sent to the current address
getNewEmailConfirmationEmailEmail-change confirmation, sent to the new address once the current one approves
getDeleteAccountVerificationEmailAccount-deletion request
getAccountSetupEmailAn admin creates a user with the "Send welcome email" switch on
getContactFormEmailA visitor submits the contact form
getSubscriptionStartedEmailStripe checkout completes
getSubscriptionRenewedEmailA renewal invoice is paid
getPaymentFailedEmailAn 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 email

That 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

VariableExampleNotes
SMTP2GO_API_KEYapi-...Generated under Sending → API Keys in the SMTP2Go dashboard.
SENDER_EMAILnoreply@yourdomain.comMust 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.

On this page