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
- loops.so → Sign up
- Settings → API → Create API Key
- 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=
`\
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
✓ {message};return (
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
/>
{status === "loading" ? "..." : "Subscribe"}
);
}
`
\
Drop this anywhere in your layout.
Step 5: Verify Sending Domain
Loops won't actually send until you verify a domain.
- Loops → Settings → Domain → Add your domain
- Loops generates DNS records:
- 3x CNAME (DKIM)
- 1x MX (bounce handling)
- 1x TXT (SPF)
- 1x TXT (DMARC)
- Add all to your DNS provider
- Wait 1-6 hours for propagation
- Click Verify
Step 6: Build the Welcome Sequence
In Loops UI:
- Create Loop
- Trigger: "Contact Added"
- Email 1 (immediate): Welcome + gift + CTA
- Email 2 (Day 2): Value + subtle discount
- 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 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.
Originally published on Craftly.
Check out our premium templates:
- SaaSify — SaaS Landing Page ($49)
- Developer Portfolio ($39)
- Blog Template ($29)
- Pricing Page ($19)
Built with Next.js 16, TypeScript, and Tailwind CSS v4.
Top comments (0)