Next Starter Logo

Email

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

FunctionWhen 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

  1. 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>
  );
}
  1. Add an async helper to lib/email/index.tsx that 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" };
}
  1. 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

  1. Create an account at smtp2go.com
  2. Add and verify your sending domain
  3. Generate an API key under Sending > API Keys
  4. 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 address

Both variables are validated at startup in lib/validations/env.ts. The application will refuse to start if either is missing.

On this page