Transactional email is one of those features every SaaS needs and almost every tutorial oversimplifies. This post walks through the full pattern used in this Next.js App Router SaaS boilerplate: Resend as the delivery API, react-email for type-safe templates, and a service-layer architecture that keeps email sends out of routes and components.
Resend is a developer-first email API with a clean REST interface and a free tier that covers most early-stage products. The key advantage for a full-stack TypeScript project: the SDK is typed end-to-end and pairs directly with react-email, so your templates are React components -- previewable in the browser, refactorable with your IDE, and testable like any other function.
Add the packages:
npm install resend @react-email/components react-email
Add the API key to .env:
RESEND_API_KEY=re_your_key_here
EMAIL_FROM=onboarding@resend.dev
During development you can send from onboarding@resend.dev without domain verification. Production requires a verified sending domain added in the Resend dashboard.
Create lib/email.ts as the single send entry point for the whole app:
import { Resend } from 'resend';
import type { ReactElement } from 'react';
const resend = new Resend(process.env.RESEND_API_KEY);
export const ENABLE_WELCOME_EMAIL =
process.env.ENABLE_WELCOME_EMAIL === 'true';
export const ENABLE_EMAIL_VERIFICATION =
process.env.ENABLE_EMAIL_VERIFICATION === 'true';
interface SendEmailOptions {
to: string;
subject: string;
react: ReactElement;
}
export const emailService = {
async sendEmail({ to, subject, react }: SendEmailOptions) {
const { error } = await resend.emails.send({
from: process.env.EMAIL_FROM ?? 'onboarding@resend.dev',
to,
subject,
react,
});
if (error) throw new Error(`Email send failed: ${error.message}`);
},
};
Two rules enforced here:
emailService only from the service layer, never from routes or components..ts, not .tsx, so use React.createElement instead of JSX when constructing template elements.Templates live in emails/. Each one is a plain React component using @react-email/components primitives:
// emails/WelcomeEmail.tsx
import {
Html,
Body,
Heading,
Text,
Link,
Preview,
} from '@react-email/components';
interface WelcomeEmailProps {
appName: string;
loginUrl: string;
}
export function WelcomeEmail({ appName, loginUrl }: WelcomeEmailProps) {
return (
<Html>
<Preview>Welcome to {appName}</Preview>
<Body style={{ fontFamily: 'sans-serif', padding: '24px' }}>
<Heading>Welcome to {appName}</Heading>
<Text>
Your account is ready. Click the link below to sign in.
</Text>
<Link href={loginUrl}>Sign in</Link>
</Body>
</Html>
);
}
Use @react-email/components primitives instead of raw HTML tags. Email clients like Outlook and Gmail apply their own styles to bare <div> elements -- the components handle cross-client compatibility so you do not have to.
Here is how userService sends a welcome email after registration:
// modules/user/user.service.ts
import React from 'react';
import { emailService, ENABLE_WELCOME_EMAIL } from '@/lib/email';
import { WelcomeEmail } from '@/emails/WelcomeEmail';
export const userService = {
async register(data: RegisterInput) {
// ... hash password, insert into DB via userRepo
if (ENABLE_WELCOME_EMAIL) {
await emailService.sendEmail({
to: data.email,
subject: `Welcome to ${process.env.NEXT_PUBLIC_APP_NAME}`,
react: React.createElement(WelcomeEmail, {
appName: process.env.NEXT_PUBLIC_APP_NAME ?? 'App',
loginUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/login`,
}),
});
}
},
};
The ENABLE_WELCOME_EMAIL flag defaults to false. You will not accidentally send emails while iterating locally. Flip it to true in production environment variables when you are ready.
react-email ships a preview server:
npx react-email dev --dir emails
This starts a UI at localhost:3000 that renders every template in emails/ with live reload. Check fonts, spacing, and link targets before sending a single real email.
Password reset needs a token column in the database. Add it to the user schema:
// modules/user/user.schema.ts
resetToken: text('reset_token'),
resetTokenExpiresAt: timestamp('reset_token_expires_at'),
Run the migration:
npm run db:generate
npm run db:migrate
The service generates a token, persists it, and sends the email:
async requestPasswordReset(email: string) {
const user = await userRepo.findByEmail(email);
if (!user) return; // silent -- do not leak whether the account exists
const token = crypto.randomUUID();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await userRepo.setResetToken(user.id, token, expiresAt);
await emailService.sendEmail({
to: email,
subject: 'Reset your password',
react: React.createElement(ResetPasswordEmail, {
appName: process.env.NEXT_PUBLIC_APP_NAME ?? 'App',
resetUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/reset-password?token=${token}`,
}),
});
},
The API route stays thin -- it calls userService.requestPasswordReset(email) and returns 200 regardless of whether the address exists. This prevents user enumeration.
Before enabling email in production:
EMAIL_FROM to a verified address (no-reply@yourdomain.com)ENABLE_WELCOME_EMAIL=true in Vercel environment variablesRESEND_API_KEY via vercel env add RESEND_API_KEYThe pattern is: one emailService in lib/email.ts, templates as React components in emails/, all sends from the service layer behind feature flags, React.createElement instead of JSX in .ts files. Once this is wired up, adding new email triggers -- billing receipts, org invitations, verification flows -- is one new template file and one new emailService.sendEmail call. No new infrastructure, no new dependencies.