DevStacked
EmailJuly 26, 202629 min read

How to Send Newsletters & Bulk Emails with Resend in Next.js 16

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 "send to everyone" button either times out, hits a rate limit, or just silently stops partway through.

That's not a you-problem. It's what happens when resend.emails.send() — a function built to send one email to one 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.

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.

💡 Using an older Resend tutorial? Resend recently replaced Audiences with a Segments + Topics 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 audienceId parameter is deprecated. This guide uses the current model throughout; see Resend's migration guide if you have an existing Audiences-based setup to move over.


Why emails.send() Stops Working for Newsletters

Three separate problems stack on top of each other the moment you try to "just loop over your users and call resend.emails.send()":

1. Resend rate-limits you. Every team has a request-per-second cap on the API (2–10 requests/sec by default, depending on your account). Fire off 100 send() calls in a tight loop and you'll start getting 429 Too Many Requests responses partway through — which is almost certainly what's been happening to your newsletter.

2. Your server has a time limit. 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.

3. send() isn't the right tool for a list. 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.

Once you match the right tool to the job, all three problems mostly disappear on their own.

Which Resend Feature Should You Actually Use?

Before writing any code, pick based on what you're actually sending:

Your situationUse this
Same newsletter content to your whole subscriber listContacts + Segments + Broadcast API
Different content per recipient (e.g. personalized order updates to 50 users)Batch API
A single transactional email (welcome, password reset)Plain emails.send() — you already have this from the original Resend setup guide

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.

The New Model in One Minute

Resend's Contacts system changed shape recently, and it affects almost every code sample below, so it's worth understanding upfront:

  • Contact — a global entity tied to one email address across your entire account. There's no such thing as "the same person in two Audiences" anymore; they're one Contact that can belong to multiple Segments.
  • Segment — an internal, team-facing way to group Contacts for sending (this is what Audiences used to do). Recipients never see Segment names.
  • Topic — a recipient-facing subscription category (e.g. "Weekly Newsletter," "Product Updates"). 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.

Resend's own recommendation: use Segments purely for your internal sending organization, and use Topics for anything user-facing around subscribe/unsubscribe. That's the pattern this guide follows.


Prerequisites

This guide assumes:

  • Next.js 16 with the App Router
  • TypeScript in strict mode
  • A Resend account with a recent version of resend and a verified sending domain (covered in the Resend + Next.js 16 setup guide if you haven't done that yet)
npm install resend
// lib/resend.ts
import { Resend } from "resend";

export const resend = new Resend(process.env.RESEND_API_KEY);

Before writing any code, create two things in the Resend dashboard:

  1. A Segment (Audience → Segments → Create Segment) — this is your internal newsletter list.
  2. A Topic (Audience → Topics → Create Topic) — e.g. "Weekly Newsletter" with a default subscription of opt_in — this is what subscribers actually see and manage.

Copy both IDs into .env.local:

RESEND_SEGMENT_ID=seg_xxxxxxxxx
RESEND_NEWSLETTER_TOPIC_ID=top_xxxxxxxxx

Step 1: Stop Storing Subscribers Only in Your CMS

The root cause of "I have to resend manually from my CMS" is usually that your subscriber list lives somewhere Resend doesn't know about. Every send becomes a manual export-and-paste job.

The fix is to sync subscribers into Resend the moment they sign up, instead of only saving them in your CMS or database.

Since a subscribe form lives inside your own app (it's not an external service calling you), a Server Action is the better fit here — no separate API route, no manual fetch, just a function called directly from the form.

// actions/subscribe.ts
"use server";

import { resend } from "@/lib/resend";
import { isValidEmail } from "@/lib/helpers";

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("email") as string;
  const firstName = formData.get("firstName") as string | undefined;

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

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

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

  return { success: true };
}

What's happening here: resend.contacts.create() 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 useActionState, the same pattern used for the contact form in the original Resend guide.

💡 Tip: 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 resend.segments.create({ name }) and resend.topics.create({ name, defaultSubscription }).

⚠️ Common Mistake: 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.

If you already have an existing list sitting in your CMS or database, migrate it once with a loop against resend.contacts.create() (or a CSV import from the dashboard) — after that, every new signup stays in sync automatically.

Handling Duplicate Contacts

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: contacts.create() does not error on a duplicate email. It's easy to assume it would (and it's tempting to write a try/catch around an "already exists" 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.

// ⚠️ 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("already exists")) {
  // dead code — this condition is never true in practice
}

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: the response doesn't tell you whether this call just created a brand-new Contact or silently matched an existing one. 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.

The robust fix: stop trying to detect the duplicate case at all, and instead always reinforce the Contact's state after create(), regardless of whether it was new or existing. contacts.update(), contacts.segments.add(), and contacts.topics.update() 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.

There's one wrinkle in the current model worth knowing too: the contacts.update() endpoint only touches first_name, last_name, unsubscribed, and properties — it doesn't accept segments or topics, which is why those need their own calls.

// actions/subscribe.ts (continued)
const { error } = await resend.contacts.create({
  email,
  firstName,
  unsubscribed: false,
  segments: [{ id: SEGMENT_ID }],
  topics: [{ id: NEWSLETTER_TOPIC_ID, subscription: "opt_in" }],
});

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: "opt_in" }],
});

return { success: true };

⚠️ Common Mistake: Writing an if (error?.message.includes("already exists")) branch expecting it to handle duplicates. It won't — that branch is unreachable, because contacts.create() doesn't return an error for a duplicate email in the first place. If you want to handle "new vs. existing" 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.

⚠️ Common Mistake: Blindly setting unsubscribed: false 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 subscription back to opt_in.


Step 2: Send the Newsletter with the Broadcast API

This is the step that replaces "manually resending from my CMS" 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.

This is another case for a Server Action rather than a Route Handler — it's triggered by an admin clicking "Send" inside your own app, not by an external caller, so there's no need for a public API route.

// actions/send-newsletter.ts
"use server";

import { resend } from "@/lib/resend";

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("subject") as string;
  const html = formData.get("html") as string;

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

  const { data: broadcast, error } = await resend.broadcasts.create({
    segmentId: SEGMENT_ID,
    topicId: NEWSLETTER_TOPIC_ID,
    from: "Newsletter <newsletter@yourdomain.com>",
    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 ?? "Failed to send newsletter" };
  }

  return { success: true, broadcastId: broadcast.id };
}

What's happening here: broadcasts.create() builds the email against your Segment, and passing send: true sends it immediately in the same request — the older two-step create() then broadcasts.send() 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 "send this" — it isn't the thing looping over 100 recipients itself.

💡 Tip: The {{{RESEND_UNSUBSCRIBE_URL}}} 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.

Because this action returns fast and Resend does the heavy lifting asynchronously, this alone solves the timeout problem for a standard newsletter send — there's no long-running loop in your app to time out in the first place.

⚠️ Common Mistake: Setting send: true without realizing there's no undo — a Broadcast sent this way goes out immediately. If you want a review step, drop send: true, call resend.broadcasts.send(broadcast.id) separately once you've previewed it in the dashboard.

Scheduling Broadcasts

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 scheduledAt alongside send: true:

const { data: broadcast, error } = await resend.broadcasts.create({
  segmentId: SEGMENT_ID,
  topicId: NEWSLETTER_TOPIC_ID,
  from: "Newsletter <newsletter@yourdomain.com>",
  subject,
  html: `${html}\n\n{{{RESEND_UNSUBSCRIBE_URL}}}`,
  send: true,
  scheduledAt: "tomorrow at 9am",
});

That single option covers "send tomorrow morning" without any extra infrastructure — Resend holds the broadcast and releases it at the scheduled time.

⚠️ Common Mistake: Passing scheduledAt without send: true. Resend's API requires the two together — scheduledAt on its own is silently ignored on a plain draft-only create() call.

For recurring or CMS-driven sends where you 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:

  • Vercel Cron — simplest if you're already on Vercel; define a schedule in vercel.json that hits a Route Handler on a timer.
  • Trigger.dev — better once your scheduling logic gets more complex (retries, conditional sends, multi-step workflows).
  • GitHub Actions — a scheduled workflow that calls your app's API on a cron schedule, handy if you don't want another paid service.
  • Your CMS's own scheduler — if your CMS already supports "publish at" dates, have that trigger a webhook to your app when a newsletter post goes live, and kick off the broadcast from there.

For a single one-off "send this tomorrow morning," the native scheduledAt option above is all you need — reach for the others only once you're automating when new content gets sent, not just delaying a specific send.


Step 3: Bulk Sending Different Content Per Recipient

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 Batch API is for. It sends up to 100 separate emails in a single API call, instead of 100 separate HTTP requests.

// lib/send-bulk-emails.ts
import { resend } from "@/lib/resend";

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<BulkSendResult> => {
  const batches: BulkEmail[][] = [];

  for (let i = 0; i < 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 < 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) => ({
        from: process.env.RESEND_FROM_EMAIL,
        to: email.to,
        subject: email.subject,
        html: email.html,
      })),
      sendOptions
    );

    if (error) {
      console.error("Batch send failed:", 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
    // "Common Mistake" callout about Promise.all() on this same step.
    if (batches.length > 1 && i < batches.length - 1) {
      await new Promise((resolve) => 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;
};

What's happening here: BATCH_SIZE = 100 matches Resend's own limit — one batch API call can carry up to 100 individual emails, each with its own to, subject, and html. Splitting into chunks of 100 means a 350-person send becomes 4 API calls instead of 350. The 600ms 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.

⚠️ Common Mistake: Using Promise.all() to fire every batch at once "for speed." That's exactly what triggers 429 rate-limit errors — batches still count as one request each against your per-second limit, so send them sequentially, not concurrently.


Step 4: Preventing Timeouts on Larger Sends

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:

Option A: Run the send after the response, with after()

Next.js's after() 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.

// actions/send-personalized-batch.ts
"use server";

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

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

export const sendPersonalizedBatch = async (
  _prev: SendBatchState,
  formData: FormData
): Promise<SendBatchState> => {
  const subjectLine = (formData.get("subjectLine") as string) || "This week's update";

  const subscribers = await getAllSubscribers();

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

  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 () => {
    const result = await sendBulkEmails(
      subscribers.map((s, i) => ({
        to: s.email,
        subject: subjectLine,
        html: renderPersonalizedHtml(s.firstName, subjectLine), // Your function to render HTML
      })),
      { idempotencyPrefix: `personalized-${id}` }
    );
    await appendSendLog({
      kind: "batch",
      detail: `Personalized batch finished: ${result.sent} sent, ${result.failed} failed, ${result.batches} batch(es).`,
    });
  });

  return { message: `Send started for ${subscribers.length} local subscriber(s).` };
};

What's happening here: after() 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 "started" response instead of waiting on hundreds of emails to go out synchronously.

⚠️ Common Mistake: Assuming after() gives you unlimited extra time. It still runs within your platform's function execution ceiling — it just moves the work after the response is sent, it doesn't remove the ceiling entirely. For truly large lists, see Option B.

Option B: Offload to a real queue for very large lists

Once you're sending to thousands of people reliably and repeatedly, reach for a dedicated queue like Upstash QStash or Trigger.dev 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.

Kicking off the enqueue is still admin-triggered from inside your app, so it can be a Server Action. The endpoint QStash calls back 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).

// actions/enqueue-newsletter.ts
"use server";

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

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<EnqueueState> => {
  if (!qstashToken) {
    return {
      error:
        "QSTASH_TOKEN is not set. This step is optional — most newsletters don't need a queue. " +
        "Set it in .env.local if you want to test QStash locally (requires a public URL via ngrok/tunnel for the callback).",
    };
  }

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

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

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

  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.` };
};
// 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 "next/server";
import { sendBulkEmails, type BulkEmail } from "@/lib/send-bulk-emails";

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

  if (
    typeof body !== "object" ||
    body === null ||
    !("batch" in body) ||
    !Array.isArray((body as { batch: unknown }).batch)
  ) {
    return NextResponse.json({ error: "Invalid payload" }, { 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 });
};

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.


Broadcast vs. Batch vs. Queue: Quick Comparison

Broadcast APIBatch APIBatch API + Queue (QStash)
Best forIdentical newsletter content to a whole SegmentDifferent content per recipient, small–medium listsDifferent content per recipient, thousands+
Recipients per callEntire SegmentUp to 100Up to 100 per queued batch
Handles pacing for youYes — Resend's own infrastructureNo — you chunk and delay manuallyYes — the queue paces callbacks
Retries on failureHandled by ResendManual (idempotency keys)Automatic, built into the queue
Native schedulingYes (scheduledAt + send: true)NoYes (via the queue's own scheduling)
Setup complexityLowLow–MediumMedium–High
Trigger fromServer ActionServer Action / after()Server Action (enqueue) + Route Handler (callback)

Step 5: Avoid Duplicate Sends on Retry

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.

await resend.batch.send(emailPayload, {
  idempotencyKey: `newsletter-${newsletterId}-batch-${batchIndex}`,
});

What's happening here: 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.


Step 6: Track Bounces and Complaints with Webhooks

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.

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.

// 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 "next/server";
import { resend } from "@/lib/resend";
import { markEmailBounced, unsubscribeContact } from "@/lib/db";
import type { WebhookEventPayload } from "resend";

export const POST = async (req: NextRequest) => {
  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("svix-id") ?? "",
        timestamp: req.headers.get("svix-timestamp") ?? "",
        signature: req.headers.get("svix-signature") ?? "",
      },
      webhookSecret: process.env.RESEND_WEBHOOK_SECRET,
    });
  } catch {
    return new NextResponse("Invalid webhook signature", { status: 400 });
  }

  switch (event.type) {
    case "email.bounced": {
      for (const to of event.data.to) await markEmailBounced(to);
      break;
    }
    case "email.complained": {
      // 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 });
};

What's happening here: resend.webhooks.verify() checks the svix-* headers Resend attaches to every webhook request against your RESEND_WEBHOOK_SECRET, 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 Webhooks in your Resend dashboard to get the secret and start receiving events.

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.

⚠️ Common Mistake: Calling req.json() 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 req.text(), same as with Stripe webhooks.

💡 For the full breakdown of the signature algorithm and manual verification without the SDK helper, see Resend's own webhook verification docs.


A Quick Pre-Send Checklist

  • A Segment exists for internal sending, and a Topic exists for the recipient-facing subscription preference
  • Subscribers are synced into Resend as Contacts, not just kept in your own database
  • Every signup reinforces state with contacts.update() + contacts.segments.add() + contacts.topics.update(), rather than trying to catch an "already exists" error that never comes
  • Newsletter content goes through broadcasts.create({ segmentId, topicId, send: true }), not a manual loop
  • Recurring or future-dated sends use scheduledAt with send: true (or a scheduler triggering the action) instead of you remembering to click send
  • Personalized bulk sends use resend.batch.send() in chunks of 100, sent sequentially
  • Long-running sends are kicked off with after() or a real queue, not run synchronously in the request
  • Idempotency keys are set on batch sends you might retry
  • Subscribe and send actions are Server Actions; only the webhook and any queue callback stay as Route Handlers
  • The webhook endpoint verifies the signature with resend.webhooks.verify() before trusting the payload
  • Domain has SPF, DKIM, and DMARC configured (see the Resend setup guide if not)

Frequently Asked Questions

Do I still need resend.emails.send() for anything?

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.

What happened to Audiences?

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 audienceId parameter on Contacts and Broadcasts is deprecated in favor of segments/segmentId — see Resend's migration guide if you're moving an existing project over.

How many subscribers can a Broadcast actually handle?

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.

What's the real difference between Batch and Broadcast?

Batch sends up to 100 different emails (different recipients, potentially different content) in one API call — you're still the one deciding what goes to whom. Broadcast sends one email to an entire Segment and manages delivery pacing itself. Use Batch for personalized bulk sends, Broadcast for newsletters.

Will my subscribers get duplicate emails if something fails halfway through?

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.

Do I need a database of my own if I'm using Resend Contacts?

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.

What happens if someone subscribes twice?

Resend Contacts are global entities keyed by email, so you can't end up with two Contacts for the same address — but contacts.create() 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 "already exists" error branch expecting it to catch this case; it won't fire. Instead, always follow create() with contacts.update() + contacts.segments.add() + contacts.topics.update(), which is what Step 1's code does — those calls are safe to run unconditionally on every signup, new or repeat.

Can I schedule a newsletter to go out tomorrow morning?

Yes — pass send: true and scheduledAt: "tomorrow at 9am" (or any natural-language time) to broadcasts.create() 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 when the send gets triggered in the first place, like a recurring weekly newsletter tied to your CMS's publish schedule.

Why are some endpoints Server Actions and others Route Handlers?

Server Actions are for code triggered from inside your own app — a signup form, an admin's "send" 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.

Should I use Broadcast or Batch for newsletters?

Almost always Broadcast. Only use Batch if every recipient receives different content.


Wrapping Up

The "resending manually from my CMS" 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.

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.

📦 Source Code: View on GitHub

Continue Learning

ResendNext.js 16EmailNewsletterBulk EmailTypeScriptApp Router
Share On