Craftly
Back to blog
· 6 min read

I Set Up Email Marketing for My Next.js Business in 30 Minutes (Loops.so)

Next.js Marketing Build in Public

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.

Ready to ship faster?

Browse our collection of production-ready templates.

Browse templates