Next Starter Logo

Email

How Next Starter sends transactional emails with SMTP2Go, including configuration, the sendEmail function, built-in HTML templates, disposable email blocking, and adding custom templates.

Overview

Next Starter sends transactional emails through SMTP2Go. All email sending is centralized in lib/email.ts, which exposes a sendEmail function and a set of pre-built HTML template helpers.

Emails are only sent from server-side code: Server Actions, Better Auth hooks, or API routes. They never touch the client.

Configuration

The SMTP2Go client is initialized in lib/email.ts using the API key from your environment:

// lib/email.ts
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 address, subject, and an HTML string:

interface SendEmailOptions {
  to: string;
  subject: string;
  html: string;
}

export async function sendEmail({ to, subject, html }: SendEmailOptions) {
  try {
    const mailService = smtp2go
      .mail()
      .to({ email: to })
      .from({ email: env.SENDER_EMAIL, name: APP_CONFIG.name })
      .subject(subject)
      .html(html);

    const response = await smtp2go.client().consume(mailService);
    return { success: true, messageId: response.id };
  } 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, and sends the email to the address configured in APP_CONFIG.email:

"use server";

import { sendEmail, getContactFormEmailHtml } 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}`,
    html: getContactFormEmailHtml(name, email, message),
  });

  if (!result.success) {
    return { success: false, error: "Failed to send message" };
  }

  return { success: true };
}

HTML Email Templates

All templates share a common layout built with inline CSS so they render correctly across email clients. The base template is assembled by getEmailTemplate() inside lib/email.ts and uses brand colors and font settings from lib/config.ts.

Built-in Templates

FunctionWhen it is sent
getVerificationEmailHtmlOn sign-up, to verify the email address
getPasswordResetEmailHtmlWhen a user requests a password reset
getEmailChangeVerificationHtmlWhen a user requests an email address change, sent to their current address for approval
getDeleteAccountVerificationHtmlWhen a user requests account deletion
getAccountSetupEmailHtmlWhen an admin creates a user account manually
getContactFormEmailHtmlWhen a visitor submits the contact form
getSubscriptionStartedEmailHtmlAfter a successful Stripe checkout
getSubscriptionRenewedEmailHtmlAfter a subscription renewal invoice is paid
getPaymentFailedEmailHtmlWhen a Stripe payment fails

Adding a New Template

Add a new exported function to lib/email.ts that calls the internal getEmailTemplate helper:

export function getWelcomeEmailHtml(name: string, dashboardUrl: string) {
  return getEmailTemplate({
    title: "Welcome aboard",
    greeting: `Hello ${name},`,
    message: "Your account is ready. Click below to get started.",
    buttonText: "Go to Dashboard",
    buttonLink: dashboardUrl,
    footerText: "Thanks for joining us.",
  });
}

Then import and call it wherever you need:

import { sendEmail, getWelcomeEmailHtml } from "@/lib/email";

await sendEmail({
  to: user.email,
  subject: "Welcome!",
  html: getWelcomeEmailHtml(user.name, `${baseUrl}/dashboard`),
});

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 70,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