A practical guide
A simple, working email signup you can ship in an afternoon. No mailing-list platform required - just a tiny serverless endpoint that emails you whenever someone subscribes.
You have two realistic options for a landing-page newsletter:
This guide walks through the second approach using Resend - a developer-friendly transactional email API - and a Next.js API route. The same idea works in Express, FastAPI, SvelteKit, Astro, or any backend.
Why this works: for your first hundred subscribers, you don't need automation. You need to know who showed up. Capture the address, send yourself a notification, and graduate to a list provider when you have something to send.
landing-page-prod, and copy the key. You'll only see it once.The free tier covers 100 emails/day and 3,000/month - plenty for signup notifications.
You can send test emails from Resend's onboarding@resend.dev address immediately, but real notifications should come from your own domain so they don't land in spam.
yoursite.com).Once verified, you can send from anything at that domain - e.g. noreply@yoursite.com.
Create a .env.local file in your project root. Never commit this - make sure .env* is in your .gitignore.
# Get this from resend.com → API Keys
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
# Where signup notifications go (your own inbox)
CONTACT_EMAIL=you@yourdomain.com
Heads up: when you deploy, you'll need to re-add these in your hosting platform's environment-variable settings (Vercel, Netlify, Railway, Fly, etc.). They don't get uploaded with your code, and that's a good thing.
This is the whole backend. It accepts a POST with an email, validates it, and emails you a notification.
First, install the Resend SDK:
npm install resend
Then create the route. In a Next.js App Router project:
app/api/subscribe/route.tsimport { Resend } from "resend";
import { NextResponse } from "next/server";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req: Request) {
try {
const { email } = await req.json();
if (!email || !email.includes("@")) {
return NextResponse.json({ error: "Invalid email" }, { status: 400 });
}
await resend.emails.send({
from: "Your Site <noreply@yourdomain.com>",
to: process.env.CONTACT_EMAIL!,
subject: `New signup: ${email}`,
text: `Someone subscribed:\n\n${email}`,
});
return NextResponse.json({ ok: true });
} catch (err) {
console.error("Subscribe error:", err);
return NextResponse.json({ error: "Failed" }, { status: 500 });
}
}
That's the full backend. A few details worth knowing:
from address must use your verified domain (or onboarding@resend.dev for testing).! after CONTACT_EMAIL tells TypeScript "trust me, this is set." Make sure it actually is.The same logic in Express:
import express from "express";
import { Resend } from "resend";
const app = express();
app.use(express.json());
const resend = new Resend(process.env.RESEND_API_KEY);
app.post("/api/subscribe", async (req, res) => {
const { email } = req.body;
if (!email?.includes("@")) return res.status(400).json({ error: "Invalid" });
await resend.emails.send({
from: "Your Site <noreply@yourdomain.com>",
to: process.env.CONTACT_EMAIL,
subject: `New signup: ${email}`,
text: email,
});
res.json({ ok: true });
});
The form just POSTs an email to /api/subscribe and shows success or error state. Here's a clean React version:
"use client";
import { useState } from "react";
export default function SignupForm() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "ok" | "error">("idle");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
setStatus("loading");
try {
const res = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
setStatus(res.ok ? "ok" : "error");
if (res.ok) setEmail("");
} catch {
setStatus("error");
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
required
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={status === "loading"}
/>
<button type="submit" disabled={status === "loading"}>
{status === "loading" ? "Joining…" : "Subscribe"}
</button>
{status === "ok" && <p>Thanks - you're on the list.</p>}
{status === "error" && <p>Something went wrong. Try again?</p>}
</form>
);
}
Then drop <SignupForm /> wherever you want it on your landing page.
If you're not using React, the same form works with a tiny script:
<form id="subscribe">
<input type="email" name="email" required placeholder="you@example.com" />
<button type="submit">Subscribe</button>
<p id="msg"></p>
</form>
<script>
document.getElementById("subscribe").onsubmit = async (e) => {
e.preventDefault();
const email = e.target.email.value;
const res = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
document.getElementById("msg").textContent =
res.ok ? "Thanks!" : "Try again.";
};
</script>
npm run dev).CONTACT_EMAIL - you should see a "New signup" message within seconds.If nothing arrives:
"Subscribe error:" log.from address actually uses your verified domain.The flow is the same on any platform:
RESEND_API_KEY and CONTACT_EMAIL environment variables you used locally.On Vercel specifically you can do this from the CLI:
vercel env add RESEND_API_KEY production
vercel env add CONTACT_EMAIL production
vercel deploy --prod
Test the live form the same way you tested locally.
Once you start getting signups, the next moves - in roughly this order:
resend.emails.send call with to: email.One more thing: the smallest version of a working newsletter beats the most polished version that never ships. Get the form live this week. Worry about the welcome email and the database next week.