Skip to main content
友田 陽大
Resend & transactional email
Resend
Next.js
TypeScript
メール配信
React Email
セキュリティ
信頼性

Resend × Next.js App Router: building production-quality email sending with Route Handlers and Server Actions

A production-grade guide to calling Resend from the Next.js App Router: why you must never call it from the browser, the seven things the official minimal sample is missing, and Zod validation, rate limiting, spam absorption, HTML escaping, lazy init, idempotencyKey, and structured logging — all from code running in production.

Published
Reading time
22 min read
Author
友田 陽大
Share

The gap between "a contact form that works" and "a contact form you can run in production" is not about what happens when it succeeds. It is about what happens when it fails. When the provider returns a 5xx, when a bot hammers it once a second, when you forget one environment variable, when a retry fires — whether that turns into "the lead is gone", "the email went twice", "the build collapsed", or "nothing useful is in the logs" is what production quality actually means.

I run this portfolio site itself on Resend in production: the contact form, the lead-magnet delivery, a seven-part email course, and the post-payment delivery email after Stripe checkout — all of them call Resend from a Next.js Route Handler. I also once shipped a real outage: I put the domain-authentication DNS records on the apex instead of the sending subdomain, and /api/contact returned 502 on every submission until I found it. The design in the second half of this article came out of that recovery.

This article stays faithful to the official Resend documentation while filling in what the official samples leave out — validation, rate limiting, idempotency, logging, and how to surface failure. For the map of the whole cluster, see the Resend production guide.


First, update your mental model to August 2026

Articles written in 2024–2025, and AI-generated code trained on them, contain snippets that today fail to type-check or break silently. Fix that first.

The old understanding (drop it)Correct as of August 2026
resend is on v3 / v4The npm latest is 6.18.1 (published 2026-07-28) and engines is node >= 20. The code here is verified against the 6.4.1 type definitions installed on this site
Errors are { message, name }ErrorResponse is { message, statusCode, name }, and the whole response now carries headers, so it is { data, error, headers }
An Authorization header is enoughUser-Agent is mandatory. Requests without one are rejected with 403 / error code 1010 before they ever reach the API (the SDK and CLI set it for you)
idempotencyKey goes in the payloadIt is an option in the second argument. In the payload it is a type error, and even if it slips through it is dropped before the request is sent
The SDK never throwsNot for HTTP or network failures. But the constructor and the react: rendering path do throw
Recipient lists live in audiencesThe type definitions mark it @deprecated. New code should use segments

Some entries even disagree between surfaces: for invalid_from_address, the official Errors page says 422 while the SDK 6.4.1 constant table says 403. I cannot tell you which is authoritative, so the error classification later in this article treats error.name as primary and statusCode as secondary.


Ground rule: never call the Resend API from the browser

Call resend.emails.send() from a client component and the browser says this:

Access to XMLHttpRequest at 'https://api.resend.com/emails'
from origin 'http://localhost:3000' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

The official knowledge base devotes a whole page to this, and its solution is not a proxy or an extra header — it is one line: send the emails server-side so you do not expose your API keys and avoid CORS issues.

In other words, api.resend.com deliberately emits no Access-Control-Allow-Origin. Because a browser can never complete the request, embedding an API key in client JavaScript is a design that cannot work in the first place. The CORS error beginners try to "fix" is the guardrail telling them the key would have been public.

One Next.js-specific warning, which is my own guidance rather than a doc quote: environment variables prefixed with NEXT_PUBLIC_ are inlined into the client bundle at build time. Name it NEXT_PUBLIC_RESEND_API_KEY and the key ships as a static asset that anyone can read with view-source. Resend's own key-handling guide says never to expose a key "in the browser or other client-side code." Name the variable RESEND_API_KEY, full stop.


The minimal Route Handler (faithful to the official sample)

The App Router version from the official Next.js quickstart is the starting point.

// app/api/send/route.ts
import { EmailTemplate } from '../../../components/email-template';
import { Resend } from 'resend';

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

export async function POST() {
  try {
    const { data, error } = await resend.emails.send({
      from: 'Acme <onboarding@resend.dev>',
      to: ['delivered@resend.dev'],
      subject: 'Hello world',
      react: EmailTemplate({ firstName: 'John' }),
    });

    if (error) {
      return Response.json({ error }, { status: 500 });
    }

    return Response.json(data);
  } catch (error) {
    return Response.json({ error }, { status: 500 });
  }
}

Three things to notice. What you pass to react: is a function call, not JSX (EmailTemplate({ ... })) — an explicit official rule. The result is branched on { data, error }. And there is still a try/catch — because, as covered below, this combination can genuinely throw.

Note too that the Pages Router version on the same page returns 400 on error while the App Router version returns 500. The official docs disagree with themselves inside one page, so decide for yourself instead of copying either.

Seven reasons it works but is not production-ready

What is missingWhat happens in production
No auth, no rate limitingAnyone can POST /api/send. You have shipped a public email-sending API, and your quota and domain reputation get burned
No input validationUnexpected types and lengths flow straight into the send payload
error is returned verbatim to the clientResend's message and statusCode leak to the browser (the Vercel Functions sample returns the entire response, which leaks even more)
No idempotency keyA network retry or a double-click sends the same email twice
new Resend(...) at module scopeWithout a key the constructor throws at import time and takes the build with it
No text partWorse for plain-text clients and spam filters. One is auto-generated from html, but you do not control it
No logsYou know it failed, but not how many attempts, under which error name, from which sender

Building the production version

The snippets below are quoted from app/api/contact/route.ts as it actually runs on this site. I have kept only the parts where a decision was made.

1. Close the boundary with Zod, and share the schema with the client

Validate once, at the entrance to the route handler, with Zod. The part that matters is where the schema lives. If the form and the server each own a schema, one of them will eventually be updated alone and break.

// lib/contact-schema.ts — the single definition shared by client and server
export function buildContactSchema(m: ContactMessages) {
  return z.object({
    name: z.string().min(2, { message: m.nameMin }).max(50, { message: m.nameMax }),
    email: z.string().email({ message: m.email }),
    projectType: z.enum(PROJECT_TYPES, { message: m.projectType }),
    message: z.string().min(20, { message: m.messageMin }).max(2000, { message: m.messageMax }),
    // Honeypot: real users never see this field, so non-empty means bot.
    website: z.string().max(0, { message: "invalid" }).optional().or(z.literal("")),
    // Time spent on the form (ms). Bots submit almost instantly.
    elapsedMs: z.number().int().nonnegative().optional(),
  });
}

The same file also owns the label maps (PROJECT_TYPE_LABELS and friends), so the labels in the email body, the options in the form, and the schema enum stay in sync in one place. Spread across three files, you eventually ship an email containing the raw value dx-assessment.

One lesson from experience: I originally declared the optional radio groups as z.enum(...).optional(). React Hook Form reports an unchecked radio group as null, so every visitor who skipped an optional field had their submission silently rejected. Switching to nullish() fixed it. Decide, at the boundary, whether "not filled in" is undefined or null.

2. Rate limiting: return 429 with Retry-After and X-RateLimit-*

const limiter = getLimiter({ prefix: "contact", max: 5, windowMs: 10 * 60 * 1000 });

const limit = await limiter.check(getClientKey(request, "contact"));
if (!limit.allowed) {
  log("warn", "rate_limited");
  return NextResponse.json(
    { error: "Please wait a moment and try again" },
    {
      status: 429,
      headers: {
        // A 429 with no seconds attached never tells the client when it may
        // retry, so it just gets resent immediately and achieves nothing.
        "Retry-After": String(Math.max(1, Math.ceil((limit.resetAt - Date.now()) / 1000))),
        "X-RateLimit-Limit": "5",
        "X-RateLimit-Remaining": "0",
      },
    },
  );
}

Guard your own entrance, but keep Resend's limit in mind too. Per the official Usage Limits page the default is 10 requests per second per team, applied across every API key on the team, with no separate burst allowance — the eleventh request in the same second gets a 429. Responses carry ratelimit-limit, ratelimit-remaining, ratelimit-reset, and retry-after.

Since v6 the SDK result also carries headers, so const { data, error, headers } = await resend.emails.send(...) plus headers?.["ratelimit-remaining"] (header names are lowercase) lets you spot exhaustion before the 429 rather than after. No official page demonstrates this pattern, so treat it as my suggestion.

The rate limiter itself is covered separately in the serverless rate limiting guide for Next.js.

3. Absorb spam rather than rejecting it

function isSpam(payload: ContactRequestData): boolean {
  if (payload.website && payload.website.length > 0) return true;   // honeypot
  if (typeof payload.elapsedMs === "number" && payload.elapsedMs < 2_500) return true;
  return false;
}

if (isSpam(data)) {
  log("warn", "spam_filtered");
  // Returning 400 teaches the bot it was detected and invites evasion.
  // Looking successful and dropping the message gives it nothing.
  return NextResponse.json({ success: true, messageId: null }, { status: 200 });
}

A honeypot (the website field, invisible to real users) plus a minimum dwell time removes most automated form spam. The important part is returning 200.

4. Stop injection into the HTML email body

The moment you build a string and hand it to html:, you are doing raw HTML concatenation, not templating. Interpolate user input directly and link injection or attribute escape becomes possible.

// lib/html-escape.ts — the one implementation shared by all four Resend routes
const HTML_ESCAPE_MAP: Readonly<Record<string, string>> = Object.freeze({
  "&": "&amp;",
  "<": "&lt;",
  ">": "&gt;",
  '"': "&quot;",
  "'": "&#39;",
});

export function escapeHtml(input: string): string {
  return input.replace(/[&<>"']/g, (c) => HTML_ESCAPE_MAP[c] ?? c);
}

// At the call site: every user-supplied string goes through it, no exceptions
`<td><strong>${escapeHtml(data.name)}</strong></td>`;

Keep the escaper in one place instead of copy-pasting it. And "it is only an email, so the impact is small" is wrong: the destination is your own inbox, and that is where the HTML gets opened.

5. Initialise the Resend client lazily

This one is Next.js-specific and takes the whole build down when you hit it. The SDK constructor throws when it cannot resolve a key.

// ❌ Module scope: throws at import time wherever RESEND_API_KEY is unset
const resend = new Resend(process.env.RESEND_API_KEY);

// ✅ Lazy: the exception happens during a request and is observable as a 500
let resendClient: Resend | null = null;
function getResend(): Resend {
  if (!resendClient) {
    const apiKey = process.env.RESEND_API_KEY;
    if (!apiKey) throw new Error("RESEND_API_KEY is not configured");
    resendClient = new Resend(apiKey);
  }
  return resendClient;
}

next build loads your route-handler modules. With new Resend(...) at module scope, the build fails in any CI or preview environment without the key. Lazily, the same situation costs you one 500 on one request — and it shows up in the logs. Resend's own example repository guards with an explicit if (!process.env.RESEND_API_KEY) throw new Error(...) for the same reason.

The SDK will also read process.env.RESEND_API_KEY automatically if you call new Resend() with no argument. Convenient, but it erases from the code which variable you depend on, so I pass it explicitly.

6. idempotencyKey is the second argument (in the first, it vanishes silently)

This is the single most important line in the article. The correct form is the second argument.

await resend.emails.send(
  {
    from: 'Acme <onboarding@resend.dev>',
    to: ['delivered@resend.dev'],
    subject: 'hello world',
    html: '<p>it works!</p>',
  },
  {
    idempotencyKey: 'welcome-user/123456789',
  },
);

The "AI prompt" block at the top of each quickstart, however, puts idempotencyKey inside the payload. That does not satisfy the CreateEmailOptions type. And even if you bypass type checking, the SDK function that builds the request body (parseEmailToApiOptions) is a whitelist: everything except from, to, cc, bcc, subject, html, text, reply_to, scheduled_at, headers, tags, attachments, template, and topic_id is discarded. Idempotency disappears with no error and no warning — the classic way AI-written code starts double-sending in production.

The bare minimum of the spec: idempotency keys are supported on POST /emails and POST /emails/batch only, must be 1–256 characters, live for 24 hours, and the recommended format is <event-type>/<entity-id>, e.g. welcome-user/123456789. Three errors can come back — invalid_idempotency_key (400) plus two 409s (invalid_idempotent_request and concurrent_idempotent_requests) whose meanings and correct responses are exact opposites — and the classification table and retry design for them live in the idempotency, retries and error handling guide.

If you accept an Idempotency-Key header from the client, normalise it rather than passing it through.

function safeIdempotencyKey(raw: string | null): string {
  // Replace a malformed key instead of rejecting it — losing a lead to a
  // formatting mistake in a header is not a trade worth making.
  const cleaned = (raw ?? "").replace(/[^A-Za-z0-9._:-]/g, "").slice(0, MAX_IDEMPOTENCY_KEY_LENGTH);
  return cleaned.length > 0 ? cleaned : crypto.randomUUID();
}

Putting X-Entity-Ref-ID in the payload headers is a separate feature for preventing Gmail threading — it does not deduplicate. The payments equivalent of all this is in the Stripe production guide.

7. replyTo is the enquirer, not the sender

This is the most common design mistake in contact notifications.

client.emails.send(
  {
    from: configuredFrom,          // your own verified domain
    to: [recipient],               // your own inbox
    replyTo: data.email,           // ← the address of the person who submitted
    subject: `【${projectTypeLabel}${data.name}様からのお問い合わせ`,
    html,
    text,
  },
  { idempotencyKey },
);

Setting from to the enquirer's address is a non-starter — you cannot authenticate it on your domain, so deliverability drops and it looks like spoofing. But setting replyTo to your own address is a surprisingly common variant, and it means that hitting "reply" in your inbox replies to yourself. replyTo answers "who should receive a reply to this notification", not "who sent it".

8. Decide maxDuration and the send timeout together

export const dynamic = "force-dynamic";
/**
 * The retry loop can issue several provider round-trips plus backoff. Without an
 * explicit budget, a slow provider gets the function killed mid-retry — which
 * takes the `delivery_failed` log line with it and makes the incident invisible.
 */
export const maxDuration = 25;

/** Per-send ceiling, sized so the worst case still fits inside `maxDuration`. */
const SEND_TIMEOUT_MS = 8_000;

The point is to make "function budget > worst case of the whole retry chain" true on purpose. Set only one of them and the platform kills you before you record the failure. A provider that hangs instead of erroring has to be cut off yourself with Promise.race — the SDK has no timeout option. The function budget itself depends on your host and plan, so check yours in the Vercel Functions guide.

9. Logs: single-line JSON, no PII

function log(level: "info" | "warn" | "error", phase: string, fields: Record<string, unknown> = {}): void {
  // Multi-argument console.* output gets collapsed or truncated by log viewers,
  // and the detail you most want during an incident disappears. One event per
  // JSON line stays searchable and parseable wherever you read it.
  console[level](JSON.stringify({ route: "contact", phase, ...fields }));
}

log("error", "delivery_failed", {
  kind: result.kind,           // retryable / sender_rejected / fatal
  errorName: result.errorName, // Resend's error.name
  statusCode: result.statusCode,
  attempts: result.attempts.length,
});

Keep the fields to enums, status codes, and counts. Never names, addresses, or message bodies. Logs flow to third-party services and are retained for a long time.

One Next.js trap: enabling compiler.removeConsole naively in next.config.ts can erase every server log from the production build. On this site I narrowed it to { exclude: ["error", "warn", "info"] }. If you plan to rely on these logs during an incident, verify once with next build && next start that the output actually survives.

Classify errors by name

error.name is a string-literal union. Classify on the name, not the status code — as noted at the top, some entries disagree between the docs and the SDK. On this site lib/email-delivery.ts holds a map from error name to retryable / sender_rejected / fatal and only falls back to statusCode when the name is not in the map. (The contents of that map, and the reasoning behind each classification, belong to the idempotency, retries and error handling guide.)

Two things matter on the route-handler side. First, seeing an error-code constant in the type definitions does not mean you can import it: in the latest version that constant is gone from the published bundle entirely, and the only error-related public export is type ErrorResponse. Owning your own map is safer. Second, keep sender_rejected — "the payload or the sender itself was refused, so retrying is pointless" — as its own category. That is exactly the distinction I learned from the DNS outage: with the verified domain broken, a one-time sender fallback to onboarding@resend.dev is what saved the leads.


Sending from a Server Action

There is no Server Actions sample on the Resend documentation pages — search the entire docs corpus for use server and you get zero hits. But there is one in the official example app: contact-form, under nextjs-resend-examples/typescript in the resend/resend-examples repository and described as "Contact form with batch send via Server Actions", whose page states that this is "the recommended approach for forms in Next.js 16." It uses batch.send() to send two emails — a confirmation to the submitter and a notification to the site owner — in a single request.

'use server';

export async function submitContactForm(
  prevState: ContactFormState,
  formData: FormData,
): Promise<ContactFormState> {
  // Reuse the same Zod schema as the Route Handler. Two boundaries, one
  // definition of what valid input means.
  const parsed = contactFormSchema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { success: false, error: 'Please check your input' };

  const { data, error } = await resend.batch.send([
    { from, to: [parsed.data.email], subject: 'We received your message', react: Confirmation(parsed.data) },
    { from, to: [ownerAddress], subject: 'New contact form submission', react: Notification(parsed.data) },
  ]);

  if (error) {
    // Never put error.message on screen. Log it; return fixed copy to the user.
    console.error(JSON.stringify({ route: 'contact-action', phase: 'failed', name: error.name }));
    return { success: false, error: 'Sending failed. Please try again shortly' };
  }

  // ⚠️ batch.send nests one level deeper than emails.send: data.data holds the ids.
  console.info(JSON.stringify({ phase: 'sent', count: data?.data?.length ?? 0 }));
  return { success: true, error: null };
}

batch.send() carries up to 100 emails per request and counts as one request against the rate limit. Attachments are not supported, and under the default validation mode (strict) a single invalid entry fails the whole request. With batchValidation: 'permissive' the valid ones go out and the failures come back as errors: { index, message }[].

ConsiderationRoute HandlerServer Action
Callerfetch, external systems, other servicesa form in the same app
HTTP semanticscan return 429 and Retry-After directlycannot; express it in the returned state
With JavaScript disableddoes not workworks as a form action
Reproducing from outsidehit it with curl, easy to attach an idempotency keyassumes the form path
Best suited topublic APIs, webhooks, anything that gets retriedin-app form submission

I chose the Route Handler on this site. I wanted the rate-limit outcome expressed as a standard 429 with Retry-After, and I wanted to be able to hit production directly with curl while diagnosing an incident. The form-state side of a Server Action (useActionState) is covered in the React Hook Form and Server Actions guide.


Composing the HTML with React Email

Pass a React component to react: and the SDK renders it to HTML internally. There is an important change here since v5.0.0: @react-email/render became an optional peer dependency, imported dynamically at the point of use. If it is not installed, the SDK throws a plain Error instead of returning { data, error }.

// from the SDK bundle (dist/index.js). One of the exceptions to "the SDK never throws".
try {
  ({ render: render2 } = await import("@react-email/render"));
} catch (e) {
  throw new Error(
    "Failed to render React component. Make sure to install `@react-email/render` or `@react-email/components`."
  );
}

So an implementation that uses react: needs a try/catch — which is precisely why the official Next.js sample has one. React Email 6.0 (2026-04-16) then changed the package layout: @react-email/components and the individual packages are gone, and both the components and render now come from the single react-email package. renderAsync was removed in 5.0, and render itself is async.

My recommendation — this is a practical judgement, not an official one — is to render to an HTML string yourself rather than delegating to react:.

import { render, toPlainText } from 'react-email';

const html = await render(WelcomeEmail({ name })); // render is async
const text = toPlainText(html);                    // toPlainText is synchronous

await resend.emails.send({ from, to, subject, html, text }, { idempotencyKey });

Three reasons. You no longer depend on the SDK's dynamic import, so a dependency-resolution mistake surfaces at build time rather than at send time. You can author text explicitly (omit it and Resend generates one from html, with no control over the result; pass an empty string to opt out of generation entirely). And you can snapshot-test the rendered output.

Template design — Resend-hosted Templates, the triple-brace {{{VAR}}} variable syntax, and the draft-to-published lifecycle — is covered in the templates and React Email design guide. The payload limits are:

ItemLimit
to recipientsMax 50
Attachments40MB total per email after Base64 encoding. Not supported in batch sends
tags name and valueASCII letters, digits, underscores, and dashes only, max 256 characters each. No Japanese, no dots, no spaces
Idempotency key1–256 characters, valid for 24 hours
Scheduled sendsUp to 30 days in advance

That tag charset rule bites hard in Japanese: tags: [{ name: "種別", value: "問い合わせ" }] is rejected.


Surfacing failure (UX and accessibility)

Hardening the server is pointless if the screen just sits there when the button is pressed. I broke the contact form on this very site for 108 days without noticing. The cause was the null-rejecting Zod schema described earlier, but the reason I did not notice was different: the error was never rendered anywhere on screen. Since then I treat these three as mandatory.

1. Announce errors through aria-live. Put a live region near the submit button and write both success and failure into it. Rendering errors only at the bottom of the form reaches neither screen-reader users nor anyone scrolled above it.

<p role="status" aria-live="polite" className="min-h-6 text-sm">
  {state.error ?? (state.success ? "Message sent. I will reply within two business days" : "")}
</p>

2. Block double submission. Disabling the button on isSubmitting is the floor; you need it and the server-side idempotency key. A disabled button does not stop a network-level retry.

3. Always show which field is at fault. If a validation error targets a field that is not rendered, handleSubmit stops silently. "Nothing submits and no error appears" is almost always this. Wire up an on-invalid handler and emit it as an analytics event, and you will not lose another 108 days.


Verifying locally and in production

Start with a smoke test. For the 429 path, add -i and fire the request as many times as the limit allows, then check with your own eyes that Retry-After comes back in seconds and X-RateLimit-Remaining counts down.

curl -X POST http://localhost:3000/api/contact \
  -H 'Content-Type: application/json' \
  -d '{"name":"Test","email":"t@example.com","projectType":"project","message":"twenty or more characters of body text here"}'

Send to Resend's test addresses. Do not invent your own dummy address — mail to a non-existent domain damages your bounce rate.

AddressBehaviour reproduced
delivered@resend.devNormal delivery
bounced@resend.devBounce
complained@resend.devSpam complaint
suppressed@resend.devBlocked by the suppression list

Receiving those bounce and complaint events in your app is covered in the webhooks and signature verification guide.

Before your domain is verified, while from is onboarding@resend.dev, you can only send to the account owner's own address. Anything else returns a 403 with:

You can only send testing emails to your own email address (your-email-address@domain.com).
To send emails to other recipients, please verify a domain at resend.com/domains, and change
the `from` address to an email using this domain.

Skip past this into a production domain and you will get stuck on DNS record placement. That is exactly where I produced the 502 outage: SPF, DKIM, MX, and DMARC were all on the apex rather than the sending subdomain. Correct placement is covered in the domain authentication and deliverability guide.

Two closing tricks. First, checking production without actually sending mail: in this implementation the spam gate sits after Zod validation and before the send, so a well-formed body containing "elapsedMs": 0 reaches validation, sends nothing, and returns 200 — enough to confirm routing, validation, and rate limiting. Confirm that ordering in your own code before relying on it. Second, the SDK honours RESEND_BASE_URL and RESEND_USER_AGENT environment variables to override the base URL and User-Agent, which is handy for pointing tests or CI at a mock server. That behaviour exists only in the shipped bundle and is not documented on the docs site, so assume it can break on a version bump.


Pre-production checklist

  • The key is RESEND_API_KEY (no NEXT_PUBLIC_ prefix)
  • No new Resend(...) at module scope (lazy init)
  • Zod validation at the entrance, schema shared with the client
  • Rate limit returns 429 with Retry-After and X-RateLimit-*
  • Honeypot plus minimum dwell time, absorbed with a 200 on a hit
  • Every user string entering html: passes through escapeHtml
  • idempotencyKey is in the second argument to send()
  • replyTo is the enquirer's address
  • text: is supplied explicitly
  • maxDuration and the per-send timeout are consistent
  • Error classification keys off error.name (statusCode is secondary)
  • The Resend error object is never returned to the client
  • Logs are single-line JSON, carry no PII, and survive the production build
  • The UI has an aria-live error announcement and double-submit protection
  • You reproduced 200, 400, and 429 with curl

Summary

Calling Resend from Next.js takes ten lines. What this article added is the judgement that lives around those ten lines.

  1. Never call it from the browser — CORS is the spec, and the guardrail protecting your key
  2. One Zod schema at the boundary — share it with the client; never keep two definitions
  3. idempotencyKey is the second argument — in the first it vanishes silently and you double-send
  4. Lazy initnew Resend(...) at module scope breaks the build
  5. Classify and log errors by name — status codes disagree even within the official docs

Open your own route handler and check just two things: whether idempotencyKey is in the second argument, and where new Resend(...) sits. Both take five minutes to fix and both will matter during an incident. From here, go to the Resend production guide for the full picture, or to the webhooks and signature verification guide to follow what happens to a message after it is sent.

This article is based on the official Resend documentation (Next.js quickstart, API reference, idempotency keys, Usage Limits; as of August 2026) together with the type definitions and shipped bundle of resend@6.4.1 as installed on this site, restructured with operational judgement from running it in production. Specs, limits, and error codes change, so confirm current values on the official pages before adopting anything in production.

Frequently asked questions

Can I call Resend directly from a Next.js client component?
No. api.resend.com returns no Access-Control-Allow-Origin header, so a browser request always fails at the CORS preflight. The official knowledge base states plainly that you should send server-side so your API keys are not exposed. On top of that, any Next.js environment variable prefixed with NEXT_PUBLIC_ is inlined into the client bundle, so the moment you name it NEXT_PUBLIC_RESEND_API_KEY the key is effectively published. Put the call in a Route Handler or a Server Action.
Where does idempotencyKey actually go?
It is the second argument: resend.emails.send(payload, { idempotencyKey }). The SDK type definitions and the official idempotency-keys page agree on that form. Only the 'AI prompt' block at the top of each quickstart puts idempotencyKey inside the payload — which does not type-check, and even if you bypass the types, the SDK's request builder drops any field outside its whitelist. The result is that idempotency silently disappears with no error or warning, and retries double-send.
Should I use a Route Handler or a Server Action?
Use a Route Handler for anything that can be called from outside, anything you want to express with HTTP semantics such as 429 and Retry-After, and anything you want to reproduce or replay with curl. For a form inside the same app, a Server Action is less wiring and still works as a form action when JavaScript is disabled. Resend's official example app implements its contact form as a Server Action and calls it 'the recommended approach for forms in Next.js 16.' If you keep both, put the validation and sending logic in one shared module instead of duplicating it.
Is it true that the SDK can throw when you use React Email?
Yes. resend@6 treats @react-email/render as an optional peer dependency and imports it dynamically. If you use the react: option without it installed, the SDK throws a plain Error rather than returning { data, error }. The constructor likewise throws when no key is configured. The official line that the SDK does not throw applies only to the HTTP and network layers, so wrap those two places in try/catch.
Which addresses should I send to during development?
Use Resend's own test addresses. The documented set is delivered@resend.dev, bounced@resend.dev, complained@resend.dev, and suppressed@resend.dev, which reproduce delivery, bounce, complaint, and suppression respectively. Never invent your own fake address. Also, while your from address is onboarding@resend.dev you can only send to the account owner's own address; anything else returns a 403 validation_error.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

Transactional email infrastructure on Resend — design, implementation, and production operations

"We sent it" is not "it arrived." I close that gap in code and DNS rather than in operational vigilance: sender-domain authentication (SPF/DKIM/DMARC), a typed send path validated with Zod, idempotency keys and backoff for resilience, bounce and complaint handling over webhooks, RFC 8058 one-click unsubscribe, and structured logs that never carry PII. This site's own contact form, gated resources, email course and post-purchase mail all run on Resend in production — including the outage where misplaced authentication records took sending down entirely.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Also worth reading