I Set Up Email Marketing for My Next.js Business in 30 Minutes (Loops.so)
Every indie hacker who skipped email marketing eventually regrets it. Your X followers, GitHub stars, or Gumroad audience can disappear overnight if the platform changes. Email is the one channel you actually own.
Last week I wired email capture into my Next.js template business. From zero subscribers to a working welcome sequence in under an hour. Here's exactly how.
The Stack
- **Loops.so** — email service built for developers, $0 for up to 1,000 contacts
- **Next.js 16.2** — App Router with Route Handlers
- **Vercel** — hosting with env var management
Alternatives considered: Kit (ConvertKit), Resend, Mailchimp. Kit is too creator-focused, Resend is transactional-only, Mailchimp is ancient. Loops is the sweet spot for modern indie products.
Step 1: Sign Up and Get the API Key
1. loops.so → Sign up
2. Settings → API → Create API Key
3. Copy
Loops requires a sending domain for delivery. You can build the integration before domain is verified.
Step 2: Store the Key
```bash
# .env.local
LOOPS_API_KEY=<your-key>
```
For Vercel: Settings → Environment Variables. Production + Preview + Development.
Step 3: Create the Subscribe Route Handler
```ts
// src/app/api/subscribe/route.ts
import { NextRequest, NextResponse } from "next/server";
export const runtime = "edge";
const CREATE = "https://app.loops.so/api/v1/contacts/create";
const UPDATE = "https://app.loops.so/api/v1/contacts/update";
export async function POST(req: NextRequest) {
try {
const { email, source = "newsletter" } = await req.json();
if (!email || typeof email !== "string") {
return NextResponse.json({ error: "Email required" }, { status: 400 });
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json({ error: "Invalid email" }, { status: 400 });
}
const apiKey = process.env.LOOPS_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "Not configured" }, { status: 500 });
}
const res = await fetch(CREATE, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email, source, subscribed: true, userGroup: "newsletter",
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
// Duplicate? Update instead (idempotent)
if (res.status === 409 || data?.message?.includes("already")) {
const updateRes = await fetch(UPDATE, {
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email, subscribed: true, source }),
});
if (updateRes.ok) {
return NextResponse.json({ success: true, existing: true });
}
}
return NextResponse.json({ error: "Failed to subscribe" }, { status: 500 });
}
return NextResponse.json({ success: true });
} catch (err) {
console.error("Subscribe error:", err);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
```
Key design choices:
- `runtime = "edge"` for fast global response
- Idempotent: duplicate signups become updates
- Email regex validation
- `source` param to track origin
Step 4: Build the Subscribe Form
```tsx
"use client";
import { useState } from "react";
export function NewsletterForm() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [message, setMessage] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email || status === "loading") return;
setStatus("loading");
try {
const res = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, source: "homepage-footer" }),
});
const data = await res.json();
if (!res.ok) {
setStatus("error");
setMessage(data.error || "Something went wrong");
return;
}
setStatus("success");
setMessage(data.existing ? "You're already on the list!" : "Thanks!");
setEmail("");
} catch {
setStatus("error");
setMessage("Network error");
}
}
if (status === "success") return <div className="text-accent">✓ {message}</div>;
return (
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
<button type="submit" disabled={status === "loading"}>
{status === "loading" ? "..." : "Subscribe"}
</button>
</form>
);
}
```
Drop this anywhere in your layout.
Step 5: Verify Sending Domain
Loops won't actually send until you verify a domain.
1. Loops → Settings → Domain → Add your domain
2. Loops generates DNS records:
- 3x CNAME (DKIM)
- 1x MX (bounce handling)
- 1x TXT (SPF)
- 1x TXT (DMARC)
3. Add all to your DNS provider
4. Wait 1-6 hours for propagation
5. Click Verify
Step 6: Build the Welcome Sequence
In Loops UI:
1. Create Loop
2. Trigger: "Contact Added"
3. Email 1 (immediate): Welcome + gift + CTA
4. Email 2 (Day 2): Value + subtle discount
5. Email 3 (Day 5): Story + blog link
Activate. Every new subscriber gets a 3-part welcome automatically.
Step 7: Track Sources
Use the `source` param religiously. After 30 days you'll learn:
- "homepage-footer" converts 3x better than "blog-modal"
- One specific blog post drives your best-quality signups
- Certain channels bring higher LTV subscribers
Gold for optimization.
What I Learned
**#1. Email validation is worth it.** Users make typos. Loops charges per contact, bad emails compound.
**#2. The 409 case matters.** Returning users re-subscribing shouldn't see an error.
**#3. Welcome sequences should be written before launch.** Zero delay between first signup and first email.
**#4. Track sources.** Free optimization data.
Numbers After Week 1
- Subscribers: 1 (test)
- Open rate: N/A (just activated)
- Domain verification: Processing
Day 4 of the business. The point isn't traffic yet — it's having the system ready so when traffic shows up, I capture it.
Ship It
If you're building a digital product, set up email before launch. Every day you delay is subscribers you'll never get back.
Every [Craftly template](https://getcraftly.gumroad.com) includes the `/api/subscribe` endpoint + NewsletterForm wired up. Swap in your `LOOPS_API_KEY`, deploy, done. Next.js 16.2 + Tailwind v4 + TypeScript, from $19 to $49 or $99 bundle.