Collecting email addresses and sending professional emails is essential for any business website. Resend provides a modern, developer-friendly email API. Here is how to build a complete signup flow.
Step 1: Set Up Resend
- Create an account at resend.com
- Add and verify your domain (Settings > Domains)
- Create an API key (Settings > API Keys)
# .env.local
RESEND_API_KEY=re_123456789
Step 2: Install Dependencies
pnpm add resend
Step 3: Create the API Route
// app/api/subscribe/route.ts
import { NextResponse } from "next/server";
import { Resend } from "resend";
import { z } from "zod";
const resend = new Resend(process.env.RESEND_API_KEY);
const subscribeSchema = z.object({
email: z.string().email("Invalid email address"),
});
export async function POST(request: Request) {
try {
const body = await request.json();
const result = subscribeSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: "Invalid email address" },
{ status: 400 }
);
}
const { email } = result.data;
// Add to Resend audience
await resend.contacts.create({
email,
audienceId: process.env.RESEND_AUDIENCE_ID!,
});
// Send welcome email
await resend.emails.send({
from: "Your Company <hello@yourdomain.com>",
to: email,
subject: "Welcome to our newsletter",
html: getWelcomeEmailHTML(),
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Subscribe error:", error);
return NextResponse.json(
{ error: "Failed to subscribe" },
{ status: 500 }
);
}
}
function getWelcomeEmailHTML(): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 40px 20px; background-color: #f5f5f5;">
<div style="max-width: 560px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; padding: 40px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h1 style="font-size: 24px; font-weight: 700; color: #0a0a0a; margin: 0 0 16px;">
Welcome aboard
</h1>
<p style="font-size: 16px; line-height: 1.6; color: #525252; margin: 0 0 24px;">
Thanks for subscribing. We share practical insights on web development, design, and technology β no spam, just useful content.
</p>
<p style="font-size: 16px; line-height: 1.6; color: #525252; margin: 0;">
We publish weekly. Reply to this email anytime β we read every response.
</p>
</div>
<p style="text-align: center; font-size: 12px; color: #a3a3a3; margin-top: 24px;">
Your Company | yourdomain.com
</p>
</body>
</html>
`;
}
Step 4: Build the Signup Form
// components/newsletter/SignupForm.tsx
"use client";
import { useState } from "react";
export function NewsletterSignup() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [errorMessage, setErrorMessage] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setStatus("loading");
setErrorMessage("");
try {
const response = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to subscribe");
}
setStatus("success");
setEmail("");
} catch (error) {
setStatus("error");
setErrorMessage(error instanceof Error ? error.message : "Something went wrong");
}
}
if (status === "success") {
return (
<div className="rounded-lg bg-green-50 p-4 dark:bg-green-900/20">
<p className="font-medium text-green-800 dark:text-green-400">
Check your inbox for a welcome email.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<label htmlFor="email-signup" className="sr-only">
Email address
</label>
<input
id="email-signup"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
className="flex-1 rounded-md border px-3 py-2 text-sm"
aria-describedby={status === "error" ? "signup-error" : undefined}
/>
<button
type="submit"
disabled={status === "loading"}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{status === "loading" ? "..." : "Subscribe"}
</button>
{status === "error" && (
<p id="signup-error" className="mt-1 text-sm text-red-600" role="alert">
{errorMessage}
</p>
)}
</form>
);
}
Step 5: Using React Email for Beautiful Templates
For more complex email templates, use React Email:
pnpm add @react-email/components
// emails/welcome.tsx
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Text,
} from "@react-email/components";
export function WelcomeEmail({ name }: { name?: string }) {
return (
<Html>
<Head />
<Preview>Welcome to our newsletter</Preview>
<Body style={{ fontFamily: "sans-serif", backgroundColor: "#f5f5f5", padding: "40px 0" }}>
<Container style={{ backgroundColor: "#ffffff", borderRadius: "8px", padding: "40px", maxWidth: "560px" }}>
<Heading style={{ fontSize: "24px", fontWeight: "700" }}>
Welcome{name ? `, ${name}` : ""}
</Heading>
<Text style={{ fontSize: "16px", lineHeight: "1.6", color: "#525252" }}>
Thanks for subscribing. We share practical insights on web development,
design, and technology.
</Text>
</Container>
</Body>
</Html>
);
}
Use it in your API route:
import { WelcomeEmail } from "@/emails/welcome";
import { render } from "@react-email/render";
const html = await render(WelcomeEmail({ name: "Sarah" }));
await resend.emails.send({
from: "Your Company <hello@yourdomain.com>",
to: email,
subject: "Welcome to our newsletter",
html,
});
Step 6: Add a Honeypot for Bot Protection
<form onSubmit={handleSubmit}>
{/* Honeypot - hidden from real users, bots will fill it */}
<input
type="text"
name="website"
value=""
onChange={() => {}}
tabIndex={-1}
autoComplete="off"
style={{ position: "absolute", left: "-9999px" }}
aria-hidden="true"
/>
{/* Real fields */}
<input type="email" ... />
<button type="submit">Subscribe</button>
</form>
On the server, reject submissions where the honeypot field has a value.
Rate Limiting
Protect your endpoint:
// Simple in-memory rate limit
const submissions = new Map<string, number[]>();
function isRateLimited(ip: string): boolean {
const now = Date.now();
const windowMs = 60 * 1000; // 1 minute
const maxRequests = 3;
const timestamps = submissions.get(ip)?.filter((t) => now - t < windowMs) || [];
timestamps.push(now);
submissions.set(ip, timestamps);
return timestamps.length > maxRequests;
}
Need Email Marketing Integration?
We set up email collection, automated sequences, and transactional emails for business websites. Contact us for email marketing development.