<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged newsletter</title>
<link>https://coderlegion.com/tag/newsletter</link>
<description>Powered by Question2Answer</description>
<item>
<title>Remote SEO Consultant Newsletter — Signal Over Platform Noise</title>
<link>https://coderlegion.com/25541/remote-seo-consultant-newsletter-signal-over-platform-noise</link>
<description>&lt;p&gt;The Remote SEO Consultant newsletter is my independent email list for people who'd rather get a direct, no-spam tech newsletter than rely on a platform feed. Already 1,200+ subscribers, zero sales pitches, sent from &lt;em&gt;Emails are not allowed&lt;/em&gt;, with an unsubscribe link in every footer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Building an independent list instead of depending on platform feed algorithms — &lt;strong&gt;1,200+ subscribers already&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Consolidating followers from various platforms where consent/export terms allow it&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No sales pitches&lt;/strong&gt; — SEO/GTM notes and free tools only&lt;/li&gt;
&lt;li&gt;Sender: &lt;em&gt;Emails are not allowed&lt;/em&gt; — unsubscribe any time&lt;/li&gt;
&lt;li&gt;Please unsubscribe rather than report as spam&lt;/li&gt;
&lt;li&gt;Two sign-up pages, one shared list&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CoderLegion readers already know the tradeoff between a busy feed and a quiet, useful inbox. The &lt;strong&gt;Remote SEO Consultant newsletter&lt;/strong&gt; exists for the same reason a lot of you probably already curate your own RSS feeds and mailing lists: signal beats noise, and a platform feed rarely optimizes for that.&lt;/p&gt;
&lt;h3 id=&quot;what-s-actually-happening&quot;&gt;What's Actually Happening&lt;/h3&gt;
&lt;p&gt;I'm exporting followers and subscribers from various platforms — where each platform's terms and consent settings allow it — and consolidating them into one independent list. It's already at &lt;strong&gt;1,200+ subscribers&lt;/strong&gt;, and this post is the direct invitation for anyone here who wants in.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=6550446371900557620&quot; alt=&quot;Your feed is loud. Your inbox can be quiet&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;what-lands-in-the-inbox&quot;&gt;What Lands in the Inbox&lt;/h4&gt;
&lt;p&gt;SEO and GTM systems drawn from 18+ years of real client work — the kind behind results like 150K+ monthly users delivered for a Silicon Valley SaaS startup and 14K+ monthly enterprise users for a Californian AI startup — plus free tools I've shipped, like a Docx-to-Markdown converter and RSC Clock.&lt;/p&gt;
&lt;h4 id=&quot;what-doesn-t&quot;&gt;What Doesn't&lt;/h4&gt;
&lt;p&gt;Sales pitches. I don't like hard-sell email marketing landing in my own inbox, and I'm not running one in yours. No drip sequence, no urgency copy, no disguised pitch.&lt;/p&gt;
&lt;h3 id=&quot;the-deal-one-click-in-one-click-out&quot;&gt;The Deal: One Click In, One Click Out&lt;/h3&gt;
&lt;p&gt;Every email comes from &lt;strong&gt;&lt;em&gt;Emails are not allowed&lt;/em&gt;&lt;/strong&gt;. Every email has a working unsubscribe link in the footer — no confirmation maze, no guilt trip.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7259285951238181458&quot; alt=&quot;One click in, one click out&quot;&gt;&lt;/p&gt;
&lt;p&gt;If it's not useful, please use that link instead of reporting the email as spam or junk. A spam report can hurt deliverability for everyone else still on the list; unsubscribing costs nothing and fixes it cleanly.&lt;/p&gt;
&lt;h3 id=&quot;how-to-subscribe&quot;&gt;How to Subscribe&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://remoteseoconsultant.com/free-business-growth-tips&quot;&gt;https://remoteseoconsultant.com/free-business-growth-tips&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://istiquritconsultant.com/no-spam-tech-newsletter&quot;&gt;https://istiquritconsultant.com/no-spam-tech-newsletter&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both feed the same list, from the same sender.&lt;/p&gt;
&lt;h3 id=&quot;who-s-writing-this&quot;&gt;Who's Writing This&lt;/h3&gt;
&lt;p&gt;I'm Md. Istiqur Rahman, Remote SEO Consultant for SaaS and eCommerce companies and Remote GTM Manager for solopreneurs and entrepreneurs, at &lt;a rel=&quot;nofollow&quot; href=&quot;https://remoteseoconsultant.com&quot;&gt;remoteseoconsultant.com&lt;/a&gt;. Results I can point to directly: $284K+/month for a USA cosmetics eCommerce brand, 4.81/5 average client rating across 9 countries, 18+ years hands-on. Full services at &lt;a rel=&quot;nofollow&quot; href=&quot;https://remoteseoconsultant.com/istiqur-rahman-services/&quot;&gt;remoteseoconsultant.com/istiqur-rahman-services&lt;/a&gt;, free tools at &lt;a rel=&quot;nofollow&quot; href=&quot;https://remoteseoconsultant.com/free-tools/&quot;&gt;remoteseoconsultant.com/free-tools&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;faq&quot;&gt;FAQ&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What is this newsletter, and why is Md. Istiqur Rahman building one now?&lt;/strong&gt;&lt;br&gt;
An independent list — 1,200+ subscribers — built so readers get direct delivery instead of relying on platform feed algorithms.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Will these emails be sales pitches?&lt;/strong&gt;&lt;br&gt;
No. No drip sequence, no disguised pitches — SEO/GTM notes and free tools, sent occasionally.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where do the emails come from, and can I unsubscribe?&lt;/strong&gt;&lt;br&gt;
From &lt;em&gt;Emails are not allowed&lt;/em&gt;. Yes, an unsubscribe link is in every footer and works instantly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How do I subscribe if I want in?&lt;/strong&gt;&lt;br&gt;
remoteseoconsultant.com/free-business-growth-tips or istiquritconsultant.com/no-spam-tech-newsletter — same list either way.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What if I'm already following elsewhere — do I still need to subscribe?&lt;/strong&gt;&lt;br&gt;
Yes, if you want reliable delivery — platform feeds depend on that platform's algorithm, while the list reaches you directly.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Md. Istiqur Rahman — Remote SEO Consultant for SaaS and eCommerce, Remote GTM Manager for solopreneurs and entrepreneurs, practicing since 2008. &quot;Most businesses spend on marketing that fails to convert. They get either no traffic or spam traffic. I fix that by building precise SEO and digital marketing systems that turn visitors into loyal customers.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;More at the &lt;a rel=&quot;nofollow&quot; href=&quot;https://remoteseoconsultant.com/knowledge-base/&quot;&gt;knowledge base&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://remoteseoconsultant.com/remote-seo-consultant-website-sitemap/&quot;&gt;full sitemap&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=10596067974250542673&quot; alt=&quot;The Remote SEO Consultant newsletter at a glance — 1,200+ subscribers, zero sales pitches, two sign-up pages&quot;&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25541/remote-seo-consultant-newsletter-signal-over-platform-noise</guid>
<pubDate>Thu, 27 Aug 2026 17:00:00 +0000</pubDate>
</item>
<item>
<title>How to Send Newsletters &amp; Bulk Emails with Resend in Next.js 16</title>
<link>https://coderlegion.com/24671/how-to-send-newsletters-bulk-emails-with-resend-in-next-js-16</link>
<description>&lt;p&gt;If you've ever built a newsletter with Resend, you've probably hit this wall: everything works great for your first handful of subscribers, and then one day you're manually exporting a list from your CMS and pasting emails one by one because your &quot;send to everyone&quot; button either times out, hits a rate limit, or just silently stops partway through.&lt;/p&gt;
&lt;p&gt;That's not a you-problem. It's what happens when &lt;code&gt;resend.emails.send()&lt;/code&gt; — a function built to send &lt;strong&gt;one&lt;/strong&gt; email to &lt;strong&gt;one&lt;/strong&gt; person — gets asked to do a completely different job: blasting the same content to a list. The good news is Resend actually has purpose-built tools for this, most developers just don't reach for them because the docs default to the single-send example everywhere.&lt;/p&gt;
&lt;p&gt;By the end of this guide, you'll know exactly why bulk sends break, which Resend feature to use for which situation, and how to send emails to hundreds (or thousands) of subscribers from Next.js 16 without your route handler timing out or your requests getting rate-limited into oblivion.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Using an older Resend tutorial?&lt;/strong&gt; Resend recently replaced &lt;strong&gt;Audiences&lt;/strong&gt; with a &lt;strong&gt;Segments + Topics&lt;/strong&gt; model — Contacts are now global (one Contact per email address across your whole account), Segments handle internal list organization, and Topics handle what recipients actually see when they manage their subscription preferences. The &lt;code&gt;audienceId&lt;/code&gt; parameter is deprecated. This guide uses the current model throughout; see &lt;a rel=&quot;nofollow&quot; href=&quot;https://resend.com/docs/dashboard/segments/migrating-from-audiences-to-segments&quot;&gt;Resend's migration guide&lt;/a&gt; if you have an existing Audiences-based setup to move over.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-emails-send-stops-working-for-newsletters&quot;&gt;Why &lt;code&gt;emails.send()&lt;/code&gt; Stops Working for Newsletters&lt;/h3&gt;
&lt;p&gt;Three separate problems stack on top of each other the moment you try to &quot;just loop over your users and call &lt;code&gt;resend.emails.send()&lt;/code&gt;&quot;:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Resend rate-limits you.&lt;/strong&gt; Every team has a request-per-second cap on the API (2–10 requests/sec by default, depending on your account). Fire off 100 &lt;code&gt;send()&lt;/code&gt; calls in a tight loop and you'll start getting &lt;code&gt;429 Too Many Requests&lt;/code&gt; responses partway through — which is almost certainly what's been happening to your newsletter.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Your server has a time limit.&lt;/strong&gt; A Next.js Route Handler or Server Action running on Vercel (or most serverless hosts) has a maximum execution time. Even if you avoided the rate limit by adding delays between sends, looping through 100+ emails one at a time can easily blow past that limit — the function gets killed mid-send, and you have no idea who actually got the email.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;send()&lt;/code&gt; isn't the right tool for a list.&lt;/strong&gt; It's designed for transactional, one-recipient-at-a-time emails — a password reset, a receipt. A newsletter going to your whole subscriber base is a fundamentally different shape of problem, and Resend has features specifically built for it that most tutorials skip.&lt;/p&gt;
&lt;p&gt;Once you match the right tool to the job, all three problems mostly disappear on their own.&lt;/p&gt;
&lt;h3 id=&quot;which-resend-feature-should-you-actually-use-&quot;&gt;Which Resend Feature Should You Actually Use?&lt;/h3&gt;
&lt;p&gt;Before writing any code, pick based on what you're actually sending:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Your situation &lt;/th&gt;&lt;th&gt;Use this&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Same newsletter content to your whole subscriber list &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Contacts + Segments + Broadcast API&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Different content per recipient (e.g. personalized order updates to 50 users) &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Batch API&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A single transactional email (welcome, password reset) &lt;/td&gt;&lt;td&gt;Plain &lt;code&gt;emails.send()&lt;/code&gt; — you already have this from &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/resend-nextjs-16-guide&quot;&gt;the original Resend setup guide&lt;/a&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If you're the person who left that comment — resending a newsletter manually from a CMS — you want the first one. Broadcasts exist specifically so you never touch a manual send loop again: Resend's own infrastructure handles the queueing, throttling, and pacing for you.&lt;/p&gt;
&lt;h3 id=&quot;the-new-model-in-one-minute&quot;&gt;The New Model in One Minute&lt;/h3&gt;
&lt;p&gt;Resend's Contacts system changed shape recently, and it affects almost every code sample below, so it's worth understanding upfront:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Contact&lt;/strong&gt; — a global entity tied to one email address across your entire account. There's no such thing as &quot;the same person in two Audiences&quot; anymore; they're one Contact that can belong to multiple Segments.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Segment&lt;/strong&gt; — an internal, team-facing way to group Contacts for sending (this is what Audiences used to do). Recipients never see Segment names.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Topic&lt;/strong&gt; — a recipient-facing subscription category (e.g. &quot;Weekly Newsletter,&quot; &quot;Product Updates&quot;). This is what powers the preference page a subscriber sees when they click unsubscribe, letting them opt out of one type of email without unsubscribing from everything.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Resend's own recommendation: use &lt;strong&gt;Segments&lt;/strong&gt; purely for your internal sending organization, and use &lt;strong&gt;Topics&lt;/strong&gt; for anything user-facing around subscribe/unsubscribe. That's the pattern this guide follows.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;This guide assumes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Next.js 16&lt;/strong&gt; with the App Router&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; in strict mode&lt;/li&gt;
&lt;li&gt;A Resend account with a recent version of &lt;code&gt;resend&lt;/code&gt; and a verified sending domain (covered in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/resend-nextjs-16-guide&quot;&gt;Resend + Next.js 16 setup guide&lt;/a&gt; if you haven't done that yet)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install resend
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/resend.ts
import { Resend } from &quot;resend&quot;;

export const resend = new Resend(process.env.RESEND_API_KEY);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before writing any code, create two things in the Resend dashboard:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;Segment&lt;/strong&gt; (Audience → Segments → Create Segment) — this is your internal newsletter list.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Topic&lt;/strong&gt; (Audience → Topics → Create Topic) — e.g. &quot;Weekly Newsletter&quot; with a default subscription of &lt;code&gt;opt_in&lt;/code&gt; — this is what subscribers actually see and manage.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Copy both IDs into &lt;code&gt;.env.local&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-env&quot;&gt;RESEND_SEGMENT_ID=seg_xxxxxxxxx
RESEND_NEWSLETTER_TOPIC_ID=top_xxxxxxxxx
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-stop-storing-subscribers-only-in-your-cms&quot;&gt;Step 1: Stop Storing Subscribers Only in Your CMS&lt;/h3&gt;
&lt;p&gt;The root cause of &quot;I have to resend manually from my CMS&quot; is usually that your subscriber list lives somewhere Resend doesn't know about. Every send becomes a manual export-and-paste job.&lt;/p&gt;
&lt;p&gt;The fix is to sync subscribers into Resend the moment they sign up, instead of only saving them in your CMS or database.&lt;/p&gt;
&lt;p&gt;Since a subscribe form lives inside your own app (it's not an external service calling you), a &lt;strong&gt;Server Action&lt;/strong&gt; is the better fit here — no separate API route, no manual &lt;code&gt;fetch&lt;/code&gt;, just a function called directly from the form.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/subscribe.ts
&quot;use server&quot;;

import { resend } from &quot;@/lib/resend&quot;;
import { isValidEmail } from &quot;@/lib/helpers&quot;;

const SEGMENT_ID = process.env.RESEND_SEGMENT_ID!;
const NEWSLETTER_TOPIC_ID = process.env.RESEND_NEWSLETTER_TOPIC_ID!;

export async function subscribe(prev: unknown, formData: FormData) {
  const email = formData.get(&quot;email&quot;) as string;
  const firstName = formData.get(&quot;firstName&quot;) as string | undefined;

  if (!email || !isValidEmail(email)) {
    return { error: &quot;Please enter a valid email address&quot; };
  }

  const { error } = await resend.contacts.create({
    email,
    firstName,
    unsubscribed: false,
    segments: [{ id: SEGMENT_ID }],
    topics: [{ id: NEWSLETTER_TOPIC_ID, subscription: &quot;opt_in&quot; }],
  });

  if (error) {
    return { error: error.message };
  }

  return { success: true };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;resend.contacts.create()&lt;/code&gt; creates the Contact and, in the same call, adds them to your Segment (internal list) and opts them into your Topic (what they'll see on their preference page). You still keep them in your own database if you want (for account features, etc.), but Resend now has its own copy it can send to, with unsubscribes tracked automatically. Call this from your signup form with &lt;code&gt;useActionState&lt;/code&gt;, the same pattern used for the contact form in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/resend-nextjs-16-guide#build-a-contact-form&quot;&gt;original Resend guide&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; You don't need to create Segments or Topics programmatically for a single newsletter — create them once in the dashboard and reuse the IDs. If you do need to create them from code, it's &lt;code&gt;resend.segments.create({ name })&lt;/code&gt; and &lt;code&gt;resend.topics.create({ name, defaultSubscription })&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Only saving subscribers to your own database and never syncing them to Resend. That's exactly what forces the manual CMS export-and-resend workflow — Resend can only send to Contacts it actually knows about.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If you already have an existing list sitting in your CMS or database, migrate it once with a loop against &lt;code&gt;resend.contacts.create()&lt;/code&gt; (or a CSV import from the dashboard) — after that, every new signup stays in sync automatically.&lt;/p&gt;
&lt;h4 id=&quot;handling-duplicate-contacts&quot;&gt;Handling Duplicate Contacts&lt;/h4&gt;
&lt;p&gt;Someone submitting the signup form twice — or re-subscribing after months away — is a when, not an if. Worth knowing upfront so it doesn't surprise you: &lt;strong&gt;&lt;code&gt;contacts.create()&lt;/code&gt; does not error on a duplicate email.&lt;/strong&gt; It's easy to assume it would (and it's tempting to write a try/catch around an &quot;already exists&quot; message like the one below), but in practice, calling it again for an address that's already a Contact just succeeds quietly — no error, no second Contact created.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// ⚠️ This looks reasonable but the catch block never actually runs —
// contacts.create() doesn't throw on a duplicate email, it just succeeds.
const { error } = await resend.contacts.create({ email, firstName, segments: [...] });

if (error?.message.toLowerCase().includes(&quot;already exists&quot;)) {
  // dead code — this condition is never true in practice
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's convenient in one sense (you can't accidentally create two Contacts for one email — they're global entities keyed by address, so a second one isn't possible anyway), but it creates a different problem: &lt;strong&gt;the response doesn't tell you whether this call just created a brand-new Contact or silently matched an existing one.&lt;/strong&gt; You can't safely assume a repeat signup refreshed that Contact's name, Segment membership, or Topic subscription — the API gives no signal either way, and relying on undocumented behavior here is asking for a subscriber who resubscribes and doesn't actually get re-added to your Segment.&lt;/p&gt;
&lt;p&gt;The robust fix: stop trying to detect the duplicate case at all, and instead &lt;strong&gt;always reinforce the Contact's state after &lt;code&gt;create()&lt;/code&gt;&lt;/strong&gt;, regardless of whether it was new or existing. &lt;code&gt;contacts.update()&lt;/code&gt;, &lt;code&gt;contacts.segments.add()&lt;/code&gt;, and &lt;code&gt;contacts.topics.update()&lt;/code&gt; are all safe to call unconditionally — an update on a field that's already correct is a no-op, and adding a Contact to a Segment it's already in doesn't duplicate anything.&lt;/p&gt;
&lt;p&gt;There's one wrinkle in the current model worth knowing too: the &lt;code&gt;contacts.update()&lt;/code&gt; endpoint only touches &lt;code&gt;first_name&lt;/code&gt;, &lt;code&gt;last_name&lt;/code&gt;, &lt;code&gt;unsubscribed&lt;/code&gt;, and &lt;code&gt;properties&lt;/code&gt; — it doesn't accept &lt;code&gt;segments&lt;/code&gt; or &lt;code&gt;topics&lt;/code&gt;, which is why those need their own calls.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/subscribe.ts (continued)
const { error } = await resend.contacts.create({
  email,
  firstName,
  unsubscribed: false,
  segments: [{ id: SEGMENT_ID }],
  topics: [{ id: NEWSLETTER_TOPIC_ID, subscription: &quot;opt_in&quot; }],
});

if (error) {
  // A genuine failure — bad Segment/Topic ID, auth issue, etc. Not the
  // duplicate case, since that doesn't come back as an error at all.
  return { error: error.message };
}

// Reinforce the Contact's state either way — create() gives no signal
// about whether this address was brand-new or already existed, so don't
// assume its fields/Segment/Topic membership are already correct.
const { error: updateError } = await resend.contacts.update({
  email,
  firstName,
  unsubscribed: false, // treat a re-submission as an explicit re-opt-in
});

if (updateError) {
  return { error: updateError.message };
}

await resend.contacts.segments.add({ email, segmentId: SEGMENT_ID });
await resend.contacts.topics.update({
  email,
  topics: [{ id: NEWSLETTER_TOPIC_ID, subscription: &quot;opt_in&quot; }],
});

return { success: true };
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Writing an &lt;code&gt;if (error?.message.includes(&quot;already exists&quot;))&lt;/code&gt; branch expecting it to handle duplicates. It won't — that branch is unreachable, because &lt;code&gt;contacts.create()&lt;/code&gt; doesn't return an error for a duplicate email in the first place. If you want to handle &quot;new vs. existing&quot; differently for some reason (e.g. a different welcome email), you'll need to check your own database for whether you'd already seen that email before calling Resend at all — Resend's response won't tell you.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Blindly setting &lt;code&gt;unsubscribed: false&lt;/code&gt; on every update, including ones triggered by something other than the person resubmitting the signup form (e.g. an internal sync job). Only flip someone back to subscribed when the update is actually the result of them opting in again — otherwise you can silently resubscribe someone who deliberately unsubscribed. The same caution applies to setting a Topic's &lt;code&gt;subscription&lt;/code&gt; back to &lt;code&gt;opt_in&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-send-the-newsletter-with-the-broadcast-api&quot;&gt;Step 2: Send the Newsletter with the Broadcast API&lt;/h3&gt;
&lt;p&gt;This is the step that replaces &quot;manually resending from my CMS&quot; entirely. A Broadcast is a single email sent to an entire Segment, and Resend's own infrastructure handles pacing the sends against the rate limit — you don't write any batching or delay logic yourself.&lt;/p&gt;
&lt;p&gt;This is another case for a &lt;strong&gt;Server Action&lt;/strong&gt; rather than a Route Handler — it's triggered by an admin clicking &quot;Send&quot; inside your own app, not by an external caller, so there's no need for a public API route.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/send-newsletter.ts
&quot;use server&quot;;

import { resend } from &quot;@/lib/resend&quot;;

const SEGMENT_ID = process.env.RESEND_SEGMENT_ID!;
const NEWSLETTER_TOPIC_ID = process.env.RESEND_NEWSLETTER_TOPIC_ID!;

export async function sendNewsletter(prev: unknown, formData: FormData) {
  const subject = formData.get(&quot;subject&quot;) as string;
  const html = formData.get(&quot;html&quot;) as string;

  if (!subject || !html) {
    return { error: &quot;Subject and content are required&quot; };
  }

  const { data: broadcast, error } = await resend.broadcasts.create({
    segmentId: SEGMENT_ID,
    topicId: NEWSLETTER_TOPIC_ID,
    from: &quot;Newsletter &amp;lt;*Emails are not allowed*&amp;gt;&quot;,
    subject,
    html: `${html}\n\n{{{RESEND_UNSUBSCRIBE_URL}}}`,
    send: true, // create and send in a single call — no separate broadcasts.send() needed
  });

  if (error || !broadcast) {
    return { error: error?.message ?? &quot;Failed to send newsletter&quot; };
  }

  return { success: true, broadcastId: broadcast.id };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;broadcasts.create()&lt;/code&gt; builds the email against your Segment, and passing &lt;code&gt;send: true&lt;/code&gt; sends it immediately in the same request — the older two-step &lt;code&gt;create()&lt;/code&gt; then &lt;code&gt;broadcasts.send()&lt;/code&gt; flow still works if you want to review a draft in the dashboard first, but for a straightforward newsletter this single call is all you need. Resend queues and throttles delivery on their end, respecting Topic opt-outs and global unsubscribes automatically. The action returns almost instantly, because it's just telling Resend &quot;send this&quot; — it isn't the thing looping over 100 recipients itself.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; The &lt;code&gt;{{{RESEND_UNSUBSCRIBE_URL}}}&lt;/code&gt; placeholder gets replaced with a working, per-contact unsubscribe/preferences link. Gmail and Yahoo require a working one-click unsubscribe for bulk senders as of their 2024 sender guidelines — don't skip it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Because this action returns fast and Resend does the heavy lifting asynchronously, &lt;strong&gt;this alone solves the timeout problem&lt;/strong&gt; for a standard newsletter send — there's no long-running loop in your app to time out in the first place.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Setting &lt;code&gt;send: true&lt;/code&gt; without realizing there's no undo — a Broadcast sent this way goes out immediately. If you want a review step, drop &lt;code&gt;send: true&lt;/code&gt;, call &lt;code&gt;resend.broadcasts.send(broadcast.id)&lt;/code&gt; separately once you've previewed it in the dashboard.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;scheduling-broadcasts&quot;&gt;Scheduling Broadcasts&lt;/h4&gt;
&lt;p&gt;If you don't want to hit send manually at all — say you want every newsletter to go out at 9am the next day — pass &lt;code&gt;scheduledAt&lt;/code&gt; alongside &lt;code&gt;send: true&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const { data: broadcast, error } = await resend.broadcasts.create({
  segmentId: SEGMENT_ID,
  topicId: NEWSLETTER_TOPIC_ID,
  from: &quot;Newsletter &amp;lt;*Emails are not allowed*&amp;gt;&quot;,
  subject,
  html: `${html}\n\n{{{RESEND_UNSUBSCRIBE_URL}}}`,
  send: true,
  scheduledAt: &quot;tomorrow at 9am&quot;,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That single option covers &quot;send tomorrow morning&quot; without any extra infrastructure — Resend holds the broadcast and releases it at the scheduled time.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Passing &lt;code&gt;scheduledAt&lt;/code&gt; without &lt;code&gt;send: true&lt;/code&gt;. Resend's API requires the two together — &lt;code&gt;scheduledAt&lt;/code&gt; on its own is silently ignored on a plain draft-only &lt;code&gt;create()&lt;/code&gt; call.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For recurring or CMS-driven sends where &lt;em&gt;you&lt;/em&gt; need to decide the content and trigger the call on a schedule (rather than just delaying a single send), a few options handle the triggering side:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Vercel Cron&lt;/strong&gt; — simplest if you're already on Vercel; define a schedule in &lt;code&gt;vercel.json&lt;/code&gt; that hits a Route Handler on a timer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Trigger.dev&lt;/strong&gt; — better once your scheduling logic gets more complex (retries, conditional sends, multi-step workflows).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub Actions&lt;/strong&gt; — a scheduled workflow that calls your app's API on a cron schedule, handy if you don't want another paid service.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Your CMS's own scheduler&lt;/strong&gt; — if your CMS already supports &quot;publish at&quot; dates, have that trigger a webhook to your app when a newsletter post goes live, and kick off the broadcast from there.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For a single one-off &quot;send this tomorrow morning,&quot; the native &lt;code&gt;scheduledAt&lt;/code&gt; option above is all you need — reach for the others only once you're automating &lt;em&gt;when new content gets sent&lt;/em&gt;, not just delaying a specific send.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-bulk-sending-different-content-per-recipient&quot;&gt;Step 3: Bulk Sending &lt;em&gt;Different&lt;/em&gt; Content Per Recipient&lt;/h3&gt;
&lt;p&gt;Broadcasts are for identical content sent to a Segment. If you're sending 80 different emails — say, personalized weekly stats to each user — you're back to needing multiple distinct emails, and that's what the &lt;strong&gt;Batch API&lt;/strong&gt; is for. It sends up to 100 separate emails in a single API call, instead of 100 separate HTTP requests.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// lib/send-bulk-emails.ts
import { resend } from &quot;@/lib/resend&quot;;

export interface BulkEmail {
  to: string;
  subject: string;
  html: string;
}

export interface BulkSendResult {
  sent: number;
  failed: number;
  batches: number;
}

const BATCH_SIZE = 100; // Resend's per-call limit

export const sendBulkEmails = async (
  emails: BulkEmail[],
  opts: { idempotencyPrefix?: string; delayMs?: number } = {}
): Promise&amp;lt;BulkSendResult&amp;gt; =&amp;gt; {
  const batches: BulkEmail[][] = [];

  for (let i = 0; i &amp;lt; emails.length; i += BATCH_SIZE) {
    batches.push(emails.slice(i, i + BATCH_SIZE));
  }

  const result: BulkSendResult = { sent: 0, failed: 0, batches: batches.length };

  for (let i = 0; i &amp;lt; batches.length; i++) {
    const batch = batches[i];

    const sendOptions = opts.idempotencyPrefix
      ? { idempotencyKey: `${opts.idempotencyPrefix}-batch-${i}` }
      : undefined;

    const { data, error } = await resend.batch.send(
      batch.map((email) =&amp;gt; ({
        from: process.env.RESEND_FROM_EMAIL,
        to: email.to,
        subject: email.subject,
        html: email.html,
      })),
      sendOptions
    );

    if (error) {
      console.error(&quot;Batch send failed:&quot;, error.message);
      result.failed += batch.length;
      continue;
    }

    result.sent += data?.data.length ?? 0;

    // Small pause between batches so consecutive batch calls stay
    // comfortably under the per-second rate limit — see the guide's
    // &quot;Common Mistake&quot; callout about Promise.all() on this same step.
    if (batches.length &amp;gt; 1 &amp;amp;&amp;amp; i &amp;lt; batches.length - 1) {
      await new Promise((resolve) =&amp;gt; setTimeout(resolve, opts.delayMs ?? 600));
      // The delay is only a simple example. In production, prefer 
      // handling 429 responses and Retry-After/rate-limit headers 
      // rather than relying on a fixed delay.
    }
  }

  return result;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;BATCH_SIZE = 100&lt;/code&gt; matches Resend's own limit — one batch API call can carry up to 100 individual emails, each with its own &lt;code&gt;to&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt;, and &lt;code&gt;html&lt;/code&gt;. Splitting into chunks of 100 means a 350-person send becomes 4 API calls instead of 350. The &lt;code&gt;600ms&lt;/code&gt; pause is a simple conservative example for sequential requests. Your actual limit depends on your account, so production code should handle rate-limit responses rather than relying on a fixed delay.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Using &lt;code&gt;Promise.all()&lt;/code&gt; to fire every batch at once &quot;for speed.&quot; That's exactly what triggers &lt;code&gt;429&lt;/code&gt; rate-limit errors — batches still count as one request each against your per-second limit, so send them sequentially, not concurrently.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-4-preventing-timeouts-on-larger-sends&quot;&gt;Step 4: Preventing Timeouts on Larger Sends&lt;/h3&gt;
&lt;p&gt;For a few hundred recipients, the batching above usually finishes well inside a normal request timeout. For genuinely large lists (thousands of emails), you have two solid options:&lt;/p&gt;
&lt;h4 id=&quot;option-a-run-the-send-after-the-response-with-after-&quot;&gt;Option A: Run the send after the response, with &lt;code&gt;after()&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Next.js's &lt;code&gt;after()&lt;/code&gt; API lets you return a response to the caller immediately, while your code keeps running in the background for a bit longer — useful for kicking off a bulk send without making an admin wait on a spinner. It works the same way inside a Server Action as it does in a Route Handler, so this stays a Server Action too — triggered by an admin button inside your app.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/send-personalized-batch.ts
&quot;use server&quot;;

import { after } from &quot;next/server&quot;;
import { sendBulkEmails } from &quot;@/lib/send-bulk-emails&quot;;
import { getAllSubscribers, appendSendLog } from &quot;@/lib/db&quot;;

export type SendBatchState = {
  error?: string;
  message?: string;
};

export const sendPersonalizedBatch = async (
  _prev: SendBatchState,
  formData: FormData
): Promise&amp;lt;SendBatchState&amp;gt; =&amp;gt; {
  const subjectLine = (formData.get(&quot;subjectLine&quot;) as string) || &quot;This week's update&quot;;

  const subscribers = await getAllSubscribers();

  if (subscribers.length === 0) {
    return { error: &quot;No local subscribers yet — add one on the Subscribe page first.&quot; };
  }

  const id = crypto.randomUUID();

  // Fire-and-return: the caller gets an instant response, the actual
  // send keeps running after the response has been flushed.
  after(async () =&amp;gt; {
    const result = await sendBulkEmails(
      subscribers.map((s, i) =&amp;gt; ({
        to: s.email,
        subject: subjectLine,
        html: renderPersonalizedHtml(s.firstName, subjectLine), // Your function to render HTML
      })),
      { idempotencyPrefix: `personalized-${id}` }
    );
    await appendSendLog({
      kind: &quot;batch&quot;,
      detail: `Personalized batch finished: ${result.sent} sent, ${result.failed} failed, ${result.batches} batch(es).`,
    });
  });

  return { message: `Send started for ${subscribers.length} local subscriber(s).` };
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;after()&lt;/code&gt; schedules the callback to run once the response has been sent, but the platform keeps the function alive long enough to finish it — so the admin triggering the send gets an instant &quot;started&quot; response instead of waiting on hundreds of emails to go out synchronously.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Assuming &lt;code&gt;after()&lt;/code&gt; gives you unlimited extra time. It still runs within your platform's function execution ceiling — it just moves the work &lt;em&gt;after&lt;/em&gt; the response is sent, it doesn't remove the ceiling entirely. For truly large lists, see Option B.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;option-b-offload-to-a-real-queue-for-very-large-lists&quot;&gt;Option B: Offload to a real queue for very large lists&lt;/h4&gt;
&lt;p&gt;Once you're sending to thousands of people reliably and repeatedly, reach for a dedicated queue like &lt;strong&gt;Upstash QStash&lt;/strong&gt; or &lt;strong&gt;Trigger.dev&lt;/strong&gt; instead of doing it inline at all. You publish one message per batch (or per recipient), the queue calls your endpoint back with automatic retries, rate-limit-aware pacing, and delivery guarantees — so a mid-send crash or a Vercel redeploy can't silently lose part of your list the way a bare loop can.&lt;/p&gt;
&lt;p&gt;Kicking off the enqueue is still admin-triggered from inside your app, so it can be a Server Action. The endpoint QStash calls &lt;em&gt;back&lt;/em&gt; to, however, is a different story — that request comes from an external service hitting a public URL, which is exactly what Route Handlers exist for, not Server Actions (which have no callable URL of their own).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// actions/enqueue-newsletter.ts
&quot;use server&quot;;

import { Client } from &quot;@upstash/qstash&quot;;
import { getAllSubscribers } from &quot;@/lib/db&quot;;

export type EnqueueState = {
  error?: string;
  message?: string;
};

const BATCH_SIZE = 100;
const qstashToken = process.env.QSTASH_TOKEN!;
export const enqueueNewsletter = async (
  _prev: EnqueueState,
  formData: FormData
): Promise&amp;lt;EnqueueState&amp;gt; =&amp;gt; {
  if (!qstashToken) {
    return {
      error:
        &quot;QSTASH_TOKEN is not set. This step is optional — most newsletters don't need a queue. &quot; +
        &quot;Set it in .env.local if you want to test QStash locally (requires a public URL via ngrok/tunnel for the callback).&quot;,
    };
  }

  const subjectLine = (formData.get(&quot;subjectLine&quot;) as string) || &quot;Queued update&quot;;
  const subscribers = await getAllSubscribers();

  if (subscribers.length === 0) {
    return { error: &quot;No local subscribers yet — add one on the Subscribe page first.&quot; };
  }

  const batches: { to: string; subject: string; html: string }[][] = [];
  for (let i = 0; i &amp;lt; subscribers.length; i += BATCH_SIZE) {
    batches.push(
      subscribers.slice(i, i + BATCH_SIZE).map((s) =&amp;gt; ({
        to: s.email,
        subject: subjectLine,
        html: `&amp;lt;p&amp;gt;Hi ${s.firstName || &quot;there&quot;}, ${subjectLine}&amp;lt;/p&amp;gt;`,
      }))
    );
  }

  const qstash = new Client({ token: qstashToken, baseUrl: process.env.QSTASH_BASE_URL });
  const newsletterId = crypto.randomUUID();

  for (const [i, batch] of batches.entries()) {
    await qstash.publishJSON({
      url: `${process.env.NEXT_PUBLIC_APP_URL}/api/newsletter/send-batch-worker`,
      body: { batch, newsletterId, batchIndex: i },
    });
  }

  return { message: `Queued ${batches.length} batch(es) via QStash.` };
};
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/newsletter/send-batch-worker/route.ts
// Route Handler on purpose — QStash calls this URL directly, from outside
// this app. Server Actions have no callable URL, so this piece has to stay
// a Route Handler even though everything else in the guide is an action.
import { NextResponse } from &quot;next/server&quot;;
import { sendBulkEmails, type BulkEmail } from &quot;@/lib/send-bulk-emails&quot;;

export const POST = async (request: Request) =&amp;gt; {
  const body: unknown = await request.json();

  if (
    typeof body !== &quot;object&quot; ||
    body === null ||
    !(&quot;batch&quot; in body) ||
    !Array.isArray((body as { batch: unknown }).batch)
  ) {
    return NextResponse.json({ error: &quot;Invalid payload&quot; }, { status: 400 });
  }

  const { batch, newsletterId, batchIndex } = (body as { batch: BulkEmail[]; newsletterId: string; batchIndex: string; });
  const result = await sendBulkEmails(batch, { idempotencyPrefix: `newsletter-${newsletterId}-${batchIndex}` });

  const success = result.failed === 0;
  return NextResponse.json({ received: success, result }, { status: success ? 200 : 500 });
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is more infrastructure than most newsletters need — for a straightforward list, Step 2's Broadcast API already solves the problem without a queue. Reach for QStash only once you're past a few thousand recipients or need guaranteed retries on failure.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;broadcast-vs-batch-vs-queue-quick-comparison&quot;&gt;Broadcast vs. Batch vs. Queue: Quick Comparison&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Broadcast API &lt;/th&gt;&lt;th&gt;Batch API &lt;/th&gt;&lt;th&gt;Batch API + Queue (QStash)&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Best for &lt;/td&gt;&lt;td&gt;Identical newsletter content to a whole Segment &lt;/td&gt;&lt;td&gt;Different content per recipient, small–medium lists &lt;/td&gt;&lt;td&gt;Different content per recipient, thousands+&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Recipients per call &lt;/td&gt;&lt;td&gt;Entire Segment &lt;/td&gt;&lt;td&gt;Up to 100 &lt;/td&gt;&lt;td&gt;Up to 100 per queued batch&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Handles pacing for you &lt;/td&gt;&lt;td&gt;Yes — Resend's own infrastructure &lt;/td&gt;&lt;td&gt;No — you chunk and delay manually &lt;/td&gt;&lt;td&gt;Yes — the queue paces callbacks&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Retries on failure &lt;/td&gt;&lt;td&gt;Handled by Resend &lt;/td&gt;&lt;td&gt;Manual (idempotency keys) &lt;/td&gt;&lt;td&gt;Automatic, built into the queue&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Native scheduling &lt;/td&gt;&lt;td&gt;Yes (&lt;code&gt;scheduledAt&lt;/code&gt; + &lt;code&gt;send: true&lt;/code&gt;) &lt;/td&gt;&lt;td&gt;No &lt;/td&gt;&lt;td&gt;Yes (via the queue's own scheduling)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Setup complexity &lt;/td&gt;&lt;td&gt;Low &lt;/td&gt;&lt;td&gt;Low–Medium &lt;/td&gt;&lt;td&gt;Medium–High&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Trigger from &lt;/td&gt;&lt;td&gt;Server Action &lt;/td&gt;&lt;td&gt;Server Action / &lt;code&gt;after()&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Server Action (enqueue) + Route Handler (callback)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-5-avoid-duplicate-sends-on-retry&quot;&gt;Step 5: Avoid Duplicate Sends on Retry&lt;/h3&gt;
&lt;p&gt;If a batch call fails partway through and you retry it, you don't want to double-email anyone who already received it. Pass an idempotency key so Resend recognizes a retried request and returns the original result instead of sending again.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;await resend.batch.send(emailPayload, {
  idempotencyKey: `newsletter-${newsletterId}-batch-${batchIndex}`,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; as long as the same key is reused for a retry of that exact batch, Resend treats it as the same request rather than a new send — so a network blip on your end doesn't turn into duplicate inboxes for your subscribers.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-6-track-bounces-and-complaints-with-webhooks&quot;&gt;Step 6: Track Bounces and Complaints with Webhooks&lt;/h3&gt;
&lt;p&gt;A healthy sender reputation depends on removing bad addresses, not just sending and hoping. Set up a webhook endpoint so Resend tells you when something goes wrong with a specific email.&lt;/p&gt;
&lt;p&gt;This one has to stay a Route Handler no matter what — Resend's servers are the ones calling it, so it needs a real, public URL.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// app/api/webhooks/resend/route.ts
// Route Handler on purpose — Resend's servers call this URL directly, so it
// needs a real, public URL. This can't be a Server Action.
import { NextRequest, NextResponse } from &quot;next/server&quot;;
import { resend } from &quot;@/lib/resend&quot;;
import { markEmailBounced, unsubscribeContact } from &quot;@/lib/db&quot;;
import type { WebhookEventPayload } from &quot;resend&quot;;

export const POST = async (req: NextRequest) =&amp;gt; {
  const payload = await req.text(); // must be the raw body — signature check needs it exactly as sent

  let event: WebhookEventPayload;
  try {
    event = resend.webhooks.verify({
      payload,
      headers: {
        id: req.headers.get(&quot;svix-id&quot;) ?? &quot;&quot;,
        timestamp: req.headers.get(&quot;svix-timestamp&quot;) ?? &quot;&quot;,
        signature: req.headers.get(&quot;svix-signature&quot;) ?? &quot;&quot;,
      },
      webhookSecret: process.env.RESEND_WEBHOOK_SECRET,
    });
  } catch {
    return new NextResponse(&quot;Invalid webhook signature&quot;, { status: 400 });
  }

  switch (event.type) {
    case &quot;email.bounced&quot;: {
      for (const to of event.data.to) await markEmailBounced(to);
      break;
    }
    case &quot;email.complained&quot;: {
      // Recipient marked it as spam — unsubscribe them immediately
      for (const to of event.data.to) await unsubscribeContact(to);
      break;
    }
    default:
      break;
  }

  return NextResponse.json({ received: true });
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;What's happening here:&lt;/strong&gt; &lt;code&gt;resend.webhooks.verify()&lt;/code&gt; checks the &lt;code&gt;svix-*&lt;/code&gt; headers Resend attaches to every webhook request against your &lt;code&gt;RESEND_WEBHOOK_SECRET&lt;/code&gt;, throwing if the signature doesn't match. That's what proves the request actually came from Resend and not from someone who found your webhook URL and is spoofing bounce events to unsubscribe your whole list. Register the URL under &lt;strong&gt;Webhooks&lt;/strong&gt; in your Resend dashboard to get the secret and start receiving events.&lt;/p&gt;
&lt;p&gt;Bounced or complained-about addresses should stop receiving future sends — repeatedly emailing dead or complaining addresses is one of the fastest ways to tank deliverability for your whole domain.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Common Mistake:&lt;/strong&gt; Calling &lt;code&gt;req.json()&lt;/code&gt; before verifying. The signature is computed against the exact raw body Resend sent — parsing it first and re-serializing breaks the check. Always read with &lt;code&gt;req.text()&lt;/code&gt;, same as with Stripe webhooks.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;💡 For the full breakdown of the signature algorithm and manual verification without the SDK helper, see &lt;a rel=&quot;nofollow&quot; href=&quot;https://resend.com/docs/dashboard/webhooks/verify-webhooks-requests&quot;&gt;Resend's own webhook verification docs&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;a-quick-pre-send-checklist&quot;&gt;A Quick Pre-Send Checklist&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;[ ] A Segment exists for internal sending, and a Topic exists for the recipient-facing subscription preference&lt;/li&gt;
&lt;li&gt;[ ] Subscribers are synced into Resend as Contacts, not just kept in your own database&lt;/li&gt;
&lt;li&gt;[ ] Every signup reinforces state with &lt;code&gt;contacts.update()&lt;/code&gt; + &lt;code&gt;contacts.segments.add()&lt;/code&gt; + &lt;code&gt;contacts.topics.update()&lt;/code&gt;, rather than trying to catch an &quot;already exists&quot; error that never comes&lt;/li&gt;
&lt;li&gt;[ ] Newsletter content goes through &lt;code&gt;broadcasts.create({ segmentId, topicId, send: true })&lt;/code&gt;, not a manual loop&lt;/li&gt;
&lt;li&gt;[ ] Recurring or future-dated sends use &lt;code&gt;scheduledAt&lt;/code&gt; with &lt;code&gt;send: true&lt;/code&gt; (or a scheduler triggering the action) instead of you remembering to click send&lt;/li&gt;
&lt;li&gt;[ ] Personalized bulk sends use &lt;code&gt;resend.batch.send()&lt;/code&gt; in chunks of 100, sent sequentially&lt;/li&gt;
&lt;li&gt;[ ] Long-running sends are kicked off with &lt;code&gt;after()&lt;/code&gt; or a real queue, not run synchronously in the request&lt;/li&gt;
&lt;li&gt;[ ] Idempotency keys are set on batch sends you might retry&lt;/li&gt;
&lt;li&gt;[ ] Subscribe and send actions are Server Actions; only the webhook and any queue callback stay as Route Handlers&lt;/li&gt;
&lt;li&gt;[ ] The webhook endpoint verifies the signature with &lt;code&gt;resend.webhooks.verify()&lt;/code&gt; before trusting the payload&lt;/li&gt;
&lt;li&gt;[ ] Domain has SPF, DKIM, and DMARC configured (see the &lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/resend-nextjs-16-guide&quot;&gt;Resend setup guide&lt;/a&gt; if not)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;frequently-asked-questions&quot;&gt;Frequently Asked Questions&lt;/h3&gt;
&lt;h4 id=&quot;do-i-still-need-resend-emails-send-for-anything-&quot;&gt;Do I still need &lt;code&gt;resend.emails.send()&lt;/code&gt; for anything?&lt;/h4&gt;
&lt;p&gt;Yes — keep using it for one-off transactional emails like password resets or order confirmations. Broadcasts and the Batch API are specifically for sending to multiple people at once; a single transactional email doesn't need either.&lt;/p&gt;
&lt;h4 id=&quot;what-happened-to-audiences-&quot;&gt;What happened to Audiences?&lt;/h4&gt;
&lt;p&gt;Resend replaced Audiences with a Segments + Topics model. Contacts used to be scoped to one Audience each; now every Contact is a single global entity per email address that can belong to multiple Segments (internal organization) and Topics (recipient-facing subscription preferences). The &lt;code&gt;audienceId&lt;/code&gt; parameter on Contacts and Broadcasts is deprecated in favor of &lt;code&gt;segments&lt;/code&gt;/&lt;code&gt;segmentId&lt;/code&gt; — see &lt;a rel=&quot;nofollow&quot; href=&quot;https://resend.com/docs/dashboard/segments/migrating-from-audiences-to-segments&quot;&gt;Resend's migration guide&lt;/a&gt; if you're moving an existing project over.&lt;/p&gt;
&lt;h4 id=&quot;how-many-subscribers-can-a-broadcast-actually-handle-&quot;&gt;How many subscribers can a Broadcast actually handle?&lt;/h4&gt;
&lt;p&gt;Resend's Broadcast infrastructure is built to scale with your Segment size — you're not limited to 100 the way you are with a single Batch API call. It handles the internal pacing against rate limits for you, which is exactly the problem this guide is solving.&lt;/p&gt;
&lt;h4 id=&quot;what-s-the-real-difference-between-batch-and-broadcast-&quot;&gt;What's the real difference between Batch and Broadcast?&lt;/h4&gt;
&lt;p&gt;Batch sends up to 100 &lt;em&gt;different&lt;/em&gt; emails (different recipients, potentially different content) in one API call — you're still the one deciding what goes to whom. Broadcast sends &lt;em&gt;one&lt;/em&gt; email to an entire Segment and manages delivery pacing itself. Use Batch for personalized bulk sends, Broadcast for newsletters.&lt;/p&gt;
&lt;h4 id=&quot;will-my-subscribers-get-duplicate-emails-if-something-fails-halfway-through-&quot;&gt;Will my subscribers get duplicate emails if something fails halfway through?&lt;/h4&gt;
&lt;p&gt;Not if you're using idempotency keys on retried batch calls, and not with Broadcasts at all, since Resend's own infrastructure owns the send rather than your code retrying a loop. This is the exact failure mode manual CMS resending runs into — no idempotency, so a partial failure often means re-sending to people who already got it.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-a-database-of-my-own-if-i-m-using-resend-contacts-&quot;&gt;Do I need a database of my own if I'm using Resend Contacts?&lt;/h4&gt;
&lt;p&gt;Not strictly, but it's still a good idea to keep your own record of subscribers for anything beyond email — account linking, analytics, exporting. Just make sure every subscriber that exists in your database also gets synced to Resend, or you're back to the manual-export problem this guide fixes.&lt;/p&gt;
&lt;h4 id=&quot;what-happens-if-someone-subscribes-twice-&quot;&gt;What happens if someone subscribes twice?&lt;/h4&gt;
&lt;p&gt;Resend Contacts are global entities keyed by email, so you can't end up with two Contacts for the same address — but &lt;code&gt;contacts.create()&lt;/code&gt; won't error on a duplicate either, it just succeeds silently without telling you whether it created or matched an existing Contact. Don't write an &quot;already exists&quot; error branch expecting it to catch this case; it won't fire. Instead, always follow &lt;code&gt;create()&lt;/code&gt; with &lt;code&gt;contacts.update()&lt;/code&gt; + &lt;code&gt;contacts.segments.add()&lt;/code&gt; + &lt;code&gt;contacts.topics.update()&lt;/code&gt;, which is what Step 1's code does — those calls are safe to run unconditionally on every signup, new or repeat.&lt;/p&gt;
&lt;h4 id=&quot;can-i-schedule-a-newsletter-to-go-out-tomorrow-morning-&quot;&gt;Can I schedule a newsletter to go out tomorrow morning?&lt;/h4&gt;
&lt;p&gt;Yes — pass &lt;code&gt;send: true&lt;/code&gt; and &lt;code&gt;scheduledAt: &quot;tomorrow at 9am&quot;&lt;/code&gt; (or any natural-language time) to &lt;code&gt;broadcasts.create()&lt;/code&gt; and Resend holds it until then. You don't need Vercel Cron, Trigger.dev, or any external scheduler for a single scheduled send; those tools are for automating &lt;em&gt;when the send gets triggered in the first place&lt;/em&gt;, like a recurring weekly newsletter tied to your CMS's publish schedule.&lt;/p&gt;
&lt;h4 id=&quot;why-are-some-endpoints-server-actions-and-others-route-handlers-&quot;&gt;Why are some endpoints Server Actions and others Route Handlers?&lt;/h4&gt;
&lt;p&gt;Server Actions are for code triggered from inside your own app — a signup form, an admin's &quot;send&quot; button. Route Handlers are for anything an external service needs to call over a real URL — Resend's bounce/complaint webhooks, or a queue like QStash calling back into your app. If nothing outside your app needs to hit it directly, a Server Action is simpler; if something external does, it has to be a Route Handler.&lt;/p&gt;
&lt;h4 id=&quot;should-i-use-broadcast-or-batch-for-newsletters-&quot;&gt;Should I use Broadcast or Batch for newsletters?&lt;/h4&gt;
&lt;p&gt;Almost always Broadcast. Only use Batch if every recipient receives different content.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping Up&lt;/h3&gt;
&lt;p&gt;The &quot;resending manually from my CMS&quot; problem almost always comes down to one thing: treating a list-send like a loop of single sends instead of reaching for the tools Resend built specifically for it. Sync subscribers into a Segment the moment they sign up, send newsletters through the Broadcast API instead of a manual loop, and fall back to the chunked Batch API only when every recipient genuinely needs different content.&lt;/p&gt;
&lt;p&gt;From here, a natural next step is wiring up the webhook-based bounce handling so your list stays clean automatically, or building out a proper Topics-based preference page so subscribers can pick exactly what they want to hear from you.&lt;/p&gt;
&lt;p&gt;📦 &lt;strong&gt;Source Code:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Muhammad-Ali-sma/bulk-resend-email-demo/tree/master&quot;&gt;View on GitHub&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;continue-learning&quot;&gt;Continue Learning&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/resend-nextjs-16-guide&quot;&gt;How to Setup Resend Email in Next.js 16 (2026 Complete Beginner Guide)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-rendering-strategies-explained&quot;&gt;Next.js Rendering Strategies Explained: SSR, SSG, CSR, ISR &amp;amp; Cache Components (2026)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://devstacked.tech/blog/nextjs-16-route-handlers&quot;&gt;Route Handlers Explained in Next.js 16 (Beginner's Guide)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24671/how-to-send-newsletters-bulk-emails-with-resend-in-next-js-16</guid>
<pubDate>Sat, 15 Aug 2026 04:29:53 +0000</pubDate>
</item>
</channel>
</rss>