nextjsboilerplate Docs
Guides

Email layer

Resend setup, env vars, the shipped templates, per-category notification preferences, HMAC unsubscribe links, and the dev no-op behavior that keeps local dev frictionless.

Email layer

Transactional email is provided by src/libs/Email.ts, built on top of Resend and @react-email/components. The module exposes ready-to-customise templates, validates every recipient with Zod, honours per-category preferences, and degrades gracefully to stdout when no provider key is configured.

Env vars

# Optional. When unset, every Email.* call logs the rendered HTML to
# stdout instead of dispatching. Local installs work without any external
# account.
RESEND_API_KEY=re_your_resend_api_key

# Sender address. Default: no-reply@example.com.
EMAIL_FROM=no-reply@yourdomain.com

# Display name. Also used as the brand name in template headers and the
# signature line. Default: "Next.js Boilerplate".
EMAIL_FROM_NAME=Your SaaS Name

The # OPTIONAL tag on RESEND_API_KEY is deliberate — see "Dev no-op behaviour" below.

Provider setup (production)

  1. Create a Resend account.
  2. Verify your sending domain (DNS : MX, TXT, DKIM, optional DMARC). The Resend dashboard walks through the records.
  3. Create an API key under Settings → API Keys. Copy it into the production secret manager as RESEND_API_KEY=re_….
  4. Set EMAIL_FROM to an address on the verified domain.
  5. Set EMAIL_FROM_NAME to your brand name.

Once the four vars are in place, every Email.* call dispatches through Resend instead of stdout — no code change needed.

How to send

import { Email } from '@/libs/Email';

await Email.sendWelcome('user@example.com', 'Mo', 'en');
await Email.sendPasswordReset('user@example.com', 'https://app/reset?t=abc', 'fr');
await Email.sendVerifyEmail('user@example.com', 'https://app/verify?t=abc', 'en');
await Email.sendInvoice('user@example.com', 'https://app/inv/123', 4900, 'en'); // amount in cents

await Email.sendInvite({
  to: 'invitee@example.com',
  inviterName: 'Mo',
  orgName: 'Acme Inc',
  acceptUrl: 'https://app/accept-invite/token',
  locale: 'en',
});

await Email.sendDataExportReady(
  'user@example.com',
  'https://app/exports/signed-url',
  'en',
);

await Email.sendAccountDeletionScheduled(
  'user@example.com',
  'https://app/cancel-deletion/token',
  new Date('2026-05-30'), // scheduledAt
  'en',
);

Each method validates the recipient with Zod, renders the React Email template to HTML, and returns { success, id?, error? }. They never throw — callers can fire-and-forget with void so a Resend outage doesn't block UX :

// src/actions/register-user.ts
void Email.sendVerifyEmail(email, verifyUrl, locale);

The void keyword discards the returned Promise without awaiting it, which is ESLint-clean under the strict no-floating-promises rule.

Shipped templates

MethodSubjectWhenCategory
sendWelcomeWelcome to {{ brand }}Right after sign-up, post email-verifyproduct_updates
sendVerifyEmail"Verify your email address"On sign-up + on email-changesecurity_alerts
sendPasswordReset"Reset your password"User clicks "Forgot password" on /sign-insecurity_alerts
sendInvoiceInvoice from {{ brand }}Stripe invoice.payment_succeeded webhookbilling
sendInvite{{ inviterName }} invited youOwner/admin invites a teammateteam
sendDataExportReady"Your data export is ready"RGPD export job finishessecurity_alerts
sendAccountDeletionScheduled"Account deletion scheduled"User requests account deletionsecurity_alerts

Every template uses the shared EmailLayout component (header, brand name, footer, unsubscribe link) and the design tokens defined at the top of src/libs/Email.ts.

Per-category notification preferences (G16)

Users opt in / out of email categories from /dashboard/settings?tab=notifications. Backed by the user_preferences.notifications JSONB column. Default per-category behaviour lives in EMAIL_CATEGORY_DEFAULTS in src/models/Schema.tssecurity_alerts defaults to true and is not user-disable-able (security floor).

Categories

type EmailCategory =
  | 'security_alerts' // verify, password reset, deletion, export ready — non-optional
  | 'billing'         // invoice, payment failure, dunning
  | 'product_updates' // welcome, feature announcements, changelog
  | 'team'            // invites, role changes, member activity
  | 'marketing'       // optional newsletters, opt-in only
;

Preference resolution

Email.shouldSend(userId, category) is called before dispatch :

  1. No userId (anonymous email — verify-email-on-signup, etc.) → defaults from EMAIL_CATEGORY_DEFAULTS.
  2. No user_preferences row → defaults apply.
  3. Explicit boolean in notifications[category] → respected.
  4. Otherwise → category default.
// Inside Email.sendInvoice(...)
if (!(await Email.shouldSend(userId, 'billing'))) {
  return { success: true, skipped: 'billing_disabled' };
}

Security alerts always send regardless of preference — there is no path to disable security_alerts from the UI, and the resolver short-circuits on category before reading the row.

Every dispatched email contains a one-click unsubscribe link in the footer. The link is a stateless HMAC token — no DB row to revoke, no server-side state to clean up :

// src/libs/Email.ts
function computeUnsubscribeToken(userId: string, category: EmailCategory): string {
  const secret = process.env.AUTH_SECRET ?? 'dev_unsubscribe_secret';
  return createHmac('sha256', secret)
    .update(`${userId}|${category}`)
    .digest('hex');
}

The link target is /api/email/unsubscribe?u=<userId>&c=<category>&t=<token>. The handler verifies the HMAC, sets notifications[category] = false, and confirms visually with a "You're unsubscribed" page.

Because the token is HMAC-derived, an attacker who guesses a userId cannot unsubscribe a user — the token is unforgeable without AUTH_SECRET. CASL / CAN-SPAM compliance: the unsubscribe link works in one click, no sign-in required.

Dev no-op behaviour

When RESEND_API_KEY is not set :

  • Email.* returns { success: true } without contacting Resend.
  • The rendered HTML is printed to stdout, prefixed with the recipient and subject. Devs can copy the block into a browser to preview.
  • The dev route GET /api/dev/test-email?template=welcome|password_reset|verify|invoice&locale=en|fr triggers a send to the seeded dev user (dev@example.com). Returns 404 in production.
# Preview a welcome email rendered in French
curl 'http://localhost:3000/api/dev/test-email?template=welcome&locale=fr'

The dev route is gated on process.env.NODE_ENV !== 'production' and returns the rendered HTML in the response body so you can pipe it straight into a browser.

Adding a new template

  1. Create src/components/emails/MyTemplate.tsx, mimicking the existing templates : import EmailLayout and the shared style tokens, accept a strings prop with pre-translated copy.

    import { EmailLayout } from '@/components/emails/EmailLayout';
    
    export type MyTemplateStrings = {
      subject: string;
      headline: string;
      body: string;
      ctaLabel: string;
    };
    
    export function MyTemplate({ strings, ctaUrl, brand, unsubscribeUrl }: {
      strings: MyTemplateStrings;
      ctaUrl: string;
      brand: string;
      unsubscribeUrl: string;
    }) {
      return (
        <EmailLayout brand={brand} unsubscribeUrl={unsubscribeUrl}>
          <h1>{strings.headline}</h1>
          <p>{strings.body}</p>
          <a href={ctaUrl}>{strings.ctaLabel}</a>
        </EmailLayout>
      );
    }
  2. Add a subsection under Email.<my_template> in both src/locales/en.json and src/locales/fr.json (FR must be translated, not a placeholder — see npm run check:i18n).

  3. Add Email.sendMyTemplate(...) in src/libs/Email.ts, wiring Zod validation, the locale-scoped strings, and the dispatch() helper :

    async sendMyTemplate(
      to: string,
      ctaUrl: string,
      userId: string | null,
      locale: string,
    ): Promise<EmailResult> {
      if (!(await Email.shouldSend(userId, 'product_updates'))) {
        return { success: true, skipped: 'product_updates_disabled' };
      }
    
      const parsed = z.string().email().safeParse(to);
      if (!parsed.success) {
        return { success: false, error: 'invalid_recipient' };
      }
    
      const strings = (await loadLocale(locale)).Email.my_template;
      const html = render(<MyTemplate strings={strings} ctaUrl={ctaUrl}
        brand={brandName()} unsubscribeUrl={unsubscribeUrl(userId, 'product_updates')} />);
    
      return dispatch({ to, subject: strings.subject, html });
    }
  4. (Optional) Add the template name to the dev test route at src/app/api/dev/test-email/route.ts to preview it locally.

What's next

  • Compliance moataudit_chain emits on every email-bearing event so SOC 2 reviewers see a single immutable trail.
  • Customizing the boilerplate — swap Resend for SES, Postmark, or any other provider via the dispatch() abstraction.

On this page