DEV Community

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

Posted on • Originally published at getcraftly.dev

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=
`\

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 });
Enter fullscreen mode Exit fullscreen mode

} 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");
}
Enter fullscreen mode Exit fullscreen mode

}

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.

  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 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:

Built with Next.js 16, TypeScript, and Tailwind CSS v4.

Top comments (0)