Set up transactional email: $ARGUMENTS
$ARGUMENTS can include:
- Provider hint: "resend" (default) or "sendgrid"
- "with-templates" — scaffold common templates (welcome, magic-link, notification)
- "minimal" — just the send helper and one example template
- Empty — full Resend setup with common templates
Before Starting
- Read
package.jsonto detect framework (Next.js vs Vite + Express) - Read
src/lib/env.tsto see existing env var patterns - Check if
resendor@sendgrid/mailis already installed - Read
src/lib/config/tenant.config.tsfor brand name, sender email, support email
Architecture
src/lib/email/
client.ts ← Resend SDK singleton
send.ts ← send() helper with Result<T> pattern
templates/
WelcomeEmail.tsx
MagicLinkEmail.tsx
NotificationEmail.tsx
_Base.tsx ← Shared layout (logo, footer, brand colors)
src/app/api/email/
preview/route.ts ← GET: render template in browser (dev only)
emails/ ← (optional) standalone React Email dev server
Step 1 — Install Packages
pnpm add resend @react-email/components @react-email/render
For local preview dev server (optional):
pnpm add -D react-email
Step 2 — Environment Variables
Add to .env.local and .env.example:
# --- Email (Resend) ---
# API key from resend.com/api-keys
RESEND_API_KEY=re_...
# From address (must be a verified domain in Resend)
RESEND_FROM_EMAIL=hello@yourdomain.com
RESEND_FROM_NAME=Your App
Add to src/lib/env.ts:
server: {
RESEND_API_KEY: z.string().startsWith("re_"),
RESEND_FROM_EMAIL: z.string().email(),
RESEND_FROM_NAME: z.string().min(1),
},
Step 3 — Resend Client
Create src/lib/email/client.ts:
import { Resend } from "resend";
export const resend = new Resend(process.env.RESEND_API_KEY);
Step 4 — Send Helper
Create src/lib/email/send.ts:
import type { ReactElement } from "react";
import { render } from "@react-email/render";
import { resend } from "./client";
interface SendEmailOptions {
to: string | string[];
subject: string;
template: ReactElement;
replyTo?: string;
from?: string;
}
type Result<T> = { ok: true; data: T } | { ok: false; error: string };
export async function sendEmail(options: SendEmailOptions): Promise<Result<{ id: string }>> {
const { to, subject, template, replyTo, from } = options;
const fromAddress = from ?? `${process.env.RESEND_FROM_NAME} <${process.env.RESEND_FROM_EMAIL}>`;
try {
const { data, error } = await resend.emails.send({
from: fromAddress,
to: Array.isArray(to) ? to : [to],
subject,
react: template,
replyTo,
});
if (error) return { ok: false, error: error.message };
return { ok: true, data: { id: data!.id } };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : "Unknown error" };
}
}
Step 5 — Base Layout Template
Create src/lib/email/templates/_Base.tsx:
import {
Body,
Container,
Head,
Html,
Preview,
Section,
Text,
Hr,
Link,
} from "@react-email/components";
import { tenantConfig } from "@/lib/config/tenant.config";
interface BaseEmailProps {
preview: string;
children: React.ReactNode;
}
export function BaseEmail({ preview, children }: BaseEmailProps) {
return (
<Html>
<Head />
<Preview>{preview}</Preview>
<Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
<Container style={{ maxWidth: "560px", margin: "40px auto", backgroundColor: "#ffffff", borderRadius: "8px", padding: "40px" }}>
<Text style={{ fontSize: "24px", fontWeight: "bold", marginBottom: "24px" }}>
{tenantConfig.name}
</Text>
{children}
<Hr style={{ margin: "32px 0", borderColor: "#e6e6e6" }} />
<Section>
<Text style={{ fontSize: "12px", color: "#8898aa" }}>
{tenantConfig.name} · {tenantConfig.tagline}
</Text>
<Text style={{ fontSize: "12px", color: "#8898aa" }}>
<Link href="{{{RESEND_UNSUBSCRIBE_URL}}}">Unsubscribe</Link>
</Text>
</Section>
</Container>
</Body>
</Html>
);
}
Step 6 — Common Templates
src/lib/email/templates/WelcomeEmail.tsx
import { Button, Section, Text } from "@react-email/components";
import { BaseEmail } from "./_Base";
import { tenantConfig } from "@/lib/config/tenant.config";
interface Props {
name: string;
dashboardUrl: string;
}
export function WelcomeEmail({ name, dashboardUrl }: Props) {
return (
<BaseEmail preview={`Welcome to ${tenantConfig.name}, ${name}`}>
<Text style={{ fontSize: "20px", fontWeight: "600" }}>
Welcome, {name}
</Text>
<Text style={{ color: "#525f7f" }}>
You're now part of {tenantConfig.name}. Here's everything you need to get started.
</Text>
<Section style={{ textAlign: "center", marginTop: "24px" }}>
<Button
href={dashboardUrl}
style={{
backgroundColor: "#000",
color: "#fff",
padding: "12px 24px",
borderRadius: "6px",
textDecoration: "none",
}}
>
Go to your dashboard
</Button>
</Section>
</BaseEmail>
);
}
src/lib/email/templates/MagicLinkEmail.tsx
import { Button, Section, Text } from "@react-email/components";
import { BaseEmail } from "./_Base";
interface Props {
magicLink: string;
expiresInMinutes?: number;
}
export function MagicLinkEmail({ magicLink, expiresInMinutes = 10 }: Props) {
return (
<BaseEmail preview="Your sign-in link">
<Text style={{ fontSize: "20px", fontWeight: "600" }}>Your sign-in link</Text>
<Text style={{ color: "#525f7f" }}>
Click below to sign in. This link expires in {expiresInMinutes} minutes.
</Text>
<Section style={{ textAlign: "center", marginTop: "24px" }}>
<Button
href={magicLink}
style={{
backgroundColor: "#000",
color: "#fff",
padding: "12px 24px",
borderRadius: "6px",
textDecoration: "none",
}}
>
Sign in
</Button>
</Section>
<Text style={{ fontSize: "12px", color: "#8898aa" }}>
If you didn't request this, you can safely ignore this email.
</Text>
</BaseEmail>
);
}
src/lib/email/templates/NotificationEmail.tsx
import { Text } from "@react-email/components";
import { BaseEmail } from "./_Base";
interface Props {
title: string;
body: string;
ctaLabel?: string;
ctaUrl?: string;
}
export function NotificationEmail({ title, body, ctaLabel, ctaUrl }: Props) {
return (
<BaseEmail preview={title}>
<Text style={{ fontSize: "20px", fontWeight: "600" }}>{title}</Text>
<Text style={{ color: "#525f7f" }}>{body}</Text>
{ctaUrl && ctaLabel && (
<Text>
<a href={ctaUrl} style={{ color: "#000", fontWeight: "500" }}>{ctaLabel} →</a>
</Text>
)}
</BaseEmail>
);
}
Step 7 — Usage Examples
In an API route (server-side only)
import { sendEmail } from "@/lib/email/send";
import { WelcomeEmail } from "@/lib/email/templates/WelcomeEmail";
// After user signs up:
const result = await sendEmail({
to: user.email,
subject: `Welcome to ${tenantConfig.name}!`,
template: <WelcomeEmail name={user.name} dashboardUrl={`${APP_URL}/dashboard`} />,
});
if (!result.ok) {
console.error("Failed to send welcome email:", result.error);
// Don't block the user flow — email is non-critical
}
Step 8 — Dev Preview Route (optional)
Create src/app/api/email/preview/route.ts:
import { NextResponse } from "next/server";
import { render } from "@react-email/render";
import { WelcomeEmail } from "@/lib/email/templates/WelcomeEmail";
// Only available in development
export async function GET(request: Request) {
if (process.env.NODE_ENV === "production") {
return NextResponse.json({ error: "Not available" }, { status: 404 });
}
const { searchParams } = new URL(request.url);
const template = searchParams.get("template") ?? "welcome";
const templates: Record<string, React.ReactElement> = {
welcome: <WelcomeEmail name="Test User" dashboardUrl="http://localhost:3000/dashboard" />,
};
const html = render(templates[template] ?? templates.welcome);
return new NextResponse(html, { headers: { "Content-Type": "text/html" } });
}
Preview at: http://localhost:3000/api/email/preview?template=welcome
Step 9 — Package.json Scripts (optional)
{
"scripts": {
"email:dev": "email dev --dir src/lib/email/templates --port 3001"
}
}
Run pnpm email:dev to open the React Email dev server with live preview.
Verify
pnpm typecheck— no errors- Add a real
RESEND_API_KEYto.env.local - Hit
POST /api/email/testor callsendEmail()directly in a test - Check Resend dashboard → Emails for delivery confirmation
GET /api/email/preview— verify HTML renders correctly in browser
Anti-Patterns
- NEVER call
sendEmail()from client components — server-side only (API routes, Server Actions) - NEVER block user flows on email send failures — log and continue
- NEVER use unverified sender domains — Resend will reject or spam-filter
- NEVER hardcode recipient emails — always use user data from auth
- NEVER send emails in unit tests — mock
sendEmail()withvi.mock()