Skip to content
Chapter 48Lesson 1

Resend and the first verified send

Wire up Resend, the transactional email provider this course uses, and send your first email through a verified domain.

You have spent the last several chapters making your app write: Server Actions that take a form, validate it with Zod, and persist a row. Every one of those mutations stayed inside your own walls. The request came in, Postgres changed, and the response went back. This chapter is the first time your app has to reach out, to a system you don’t control, and put a message into a stranger’s inbox. That turns out to be a surprisingly deep problem, and most of its depth has nothing to do with the line of code that sends the message.

By the end of this lesson you will have four things: a verified sending domain, a working RESEND_API_KEY wired into your typed env, a lib/email.ts wrapper that returns the same Result shape your actions already speak, and one real email delivered through it. The send itself is three lines. The rest of the lesson, which is most of it, covers the decisions an experienced engineer makes before writing those three lines, because in email those decisions are what determine whether the message lands in the inbox or the spam folder.

The four emails the app already owes its users

Section titled “The four emails the app already owes its users”

Start from what the product needs, not from what email is. A SaaS that can’t send email can’t verify that a new user owns the address they typed. It can’t let someone who forgot their password get back in. It can’t send a sign-in link to a user who’d rather not have a password at all. It can’t email a receipt after a charge. Account verification, password reset, magic-link sign-in, and billing receipts are not nice-to-haves you bolt on later. They are load-bearing parts of the auth and billing flows you are about to build, and every one of them is blocked until email works.

So email is infrastructure, not a feature. When an experienced engineer meets a piece of infrastructure, the first question is where the responsibility sits: what does my code own, and what does the platform own? That boundary is where the bugs and the outages live.

Here is the path one email takes. Look at where the responsibilities fall.

Server Action your code
sendEmail() lib/email.ts
Resend API the vendor
recipient inbox Gmail, Outlook, …
Resend owns the wire and the IP reputation. Everything *interesting* — the domain you send from, the key that authorizes the send, the idempotency key that stops a retry from sending twice — lives on your side of the boundary.

The vendor handles the part that used to be a full-time job: speaking SMTP, maintaining a pool of sending IP addresses, and keeping those IPs in good standing with the mailbox providers. That is real value, and it is exactly the part you do not want to own. But notice that everything on the left of that final arrow is yours: the domain the mail claims to come from, the credential that authorizes it, and the key that makes a retry safe. Deliverability is the app’s responsibility, not the vendor’s, and that idea runs through the entire chapter. Resend can hand you a clean SMTP pipe, but it cannot decide for you which domain to send from, whether that domain is authenticated, or whether you’ve earned a bad reputation by emailing addresses that don’t exist. Those are your calls, and they live in your repository and your ops.

The wrapper in the middle, sendEmail(), returns the same Result discriminated union your Server Actions already return. So once it exists, sending an email from inside an action looks exactly like a database write: you call it, branch on ok, and surface the message on failure. The plumbing is familiar. The decisions are what’s new.

Why Resend, and when it isn’t the answer

Section titled “Why Resend, and when it isn’t the answer”

Before any code comes the provider decision, because picking the wrong one costs you days of setup or years of dragged-down reputation. It’s the kind of choice you should be able to defend in a design review, not just recite.

Here are the criteria an experienced engineer weighs for transactional mail in a Node and Next.js SaaS. First, can it render a React component into an email, so your templates live in the same language and component model as the rest of your app? Second, does it ship a Node SDK that types the send call, so a typo in a field is a compile error rather than a 4am bounce? Third, is there a credible free tier, so a brand-new project can send without a contract? Fourth, does it expose a webhook surface with proper signature verification, so you can later react to bounces and complaints, confident the events are real? Fifth, can a single developer get a verified domain and a first send working in an afternoon, rather than after a call with a deliverability consultant?

Resend clears all five for an early-stage SaaS, which is why it’s this course’s default. The React rendering sets up the template work in the next chapter, the typed SDK makes the send call safe, the free tier and the afternoon-to-first-send keep the cost of starting near zero, and the webhook surface is the hook a later chapter uses to track delivery. Five criteria, five passes: that’s a considered default, not a coin flip.

A default still has thresholds where something else wins, and naming them is the difference between a decision and a habit.

  • Amazon SES is the cheapest option at real scale. The cost is the setup: wiring IAM permissions, SNS topics for events, and SES itself is days of work, not minutes, and you own more of the operational surface afterward. Reach for SES once your send volume is large enough that the per-message savings pay for that ops tax, not before.
  • Postmark has the same posture as Resend: transactional-focused, deliverability-obsessed, and a clean API. It’s a completely credible swap. Choose between the two on pricing and your team’s preference, since there is no architecturally “wrong” answer between two transactional-focused providers.
  • SendGrid, Mailchimp, and the marketing-first platforms are the wrong tool here. They are built for campaigns such as newsletters, drip sequences, and blasts, and running your transactional mail through a marketing-shaped account mixes two very different reputations on one identity. A campaign’s spam complaints then drag down the deliverability of your password resets. The next two lessons go deep on why that mixing is so corrosive; for now, just don’t reach for the marketing tool to send a verification code.

The durable lesson here is not the word “Resend.” It’s the shape of the decision: for transactional mail, reach for a transactional-focused provider, never a marketing platform. Resend is this course’s pick, but the reasoning transfers to any stack you’ll ever work in. Walk the decision in the order an experienced engineer actually asks the questions: purpose first, then scale, then existing infrastructure.

Which email provider do I reach for?

ESP , transactional email , and DX are the three pieces of vocabulary that show up whenever this decision comes up, and you’ll see all three in the docs.

With the provider chosen, you can run the setup. This part is procedural, and the exact buttons in the Resend dashboard will have moved by the time you read this, so learn the shape of the process and the one reflex that never changes, not the pixel positions.

  1. Create a Resend account. Email and password, or a GitHub login. The free tier is enough for everything in this chapter.

  2. Add your sending domain, and choose the right one. This is the one real decision in the process. Do not add your bare apex domain (yourapp.com). Add a dedicated transactional subdomain like send.yourapp.com. The lesson after next explains why the subdomain. For now, take it as a given, so the from addresses you write today are honest and you won’t have to rewrite them later.

  3. Copy the DNS records Resend generates. Adding the domain produces a small set of DNS records. What each one does, and the real protocol layer underneath them, is the entire job of the next lesson. Today you only need to know that Resend hands you a set of records and you have to publish them.

  4. Add the records at your registrar. Paste the records in wherever your domain’s DNS lives, whether that’s the registrar you bought it from or a provider like Cloudflare. This is the step that ages, because every registrar’s UI is different.

  5. Wait for verification. Resend re-checks the records and flips the domain’s status to Verified once they resolve. DNS propagation can take up to 24 hours but is usually a few minutes. The badge turning green is your done signal.

One reflex matters more than any of those steps. Resend gives every new account a shared sandbox domain, onboarding@resend.dev, so you can fire a test send the instant you sign up. For real mail, it’s a trap. No email to a real user ever goes out from onboarding@resend.dev. That domain is shared across every Resend account on the planet, so its reputation is a coin flip: your message lands in spam for most providers, and worse, you start training your own users to expect your mail in the junk folder from day one. The verified domain is not bureaucracy. It is the price of admission to the inbox, and it is non-negotiable in any environment that touches real people.

API keys: sending-only, and one per environment

Section titled “API keys: sending-only, and one per environment”

Resend authenticates your sends with an API key, and the way you handle that key is a small but telling test of senior instinct. There are two things to get right.

The first is shape. Resend issues keys at two permission levels. A full-access key can do anything your account can: create domains, manage webhooks, and read everything. A sending-only key can do exactly one thing, which is to call the send endpoint. The right choice follows from least privilege : your running application gets a sending-only key, because sending is the only thing it needs to do. Full-access keys are for one-off setup scripts you run by hand, and they stay out of your app’s environment entirely. If your deployed app only ever holds a sending-only key, a leak of that key can’t be used to delete your domain or hijack your webhooks.

The second is scope: one key per environment. Your dev, preview, and production environments each get their own distinct key.

Terminal window
# .env in dev, preview, AND production — the same secret reused
RESEND_API_KEY=re_shared_key_used_in_all_three_environments

A staging leak takes production down with it. When one key authorizes every environment, a credential that leaks from a throwaway preview deploy is also your production sending key. You can’t rotate it without an outage, because rotating it breaks production too.

This pairs with key rotation : the reason you split keys is so that when one is compromised, you can rotate that one and leave the others untouched. A single shared key has no safe rotation, because every rotation is an outage. Set up the split on day one. Retrofitting it after a leak is the worst possible time to learn the lesson.

Sort these jobs by the key each one should hold. The question is never “what does this credential happen to have,” it’s “what is the least this job needs.”

Each task touches Resend. Sort it under the key shape it should use. Drag each item into the bucket it belongs to, then press Check.

Sending-only key The app's runtime — least privilege
Full-access key One-off setup scripts, run by hand
The Server Action that sends a welcome email on sign-up
The running Next.js app in production
Every per-request transactional send
A script that creates and verifies the sending domain
Registering a webhook endpoint, once, during setup

Now you write code. There are two pieces: the env slot that makes the key safe, and the wrapper module that becomes your single door to Resend.

RESEND_API_KEY is a server secret, and you already have the right place for it. Back in the data layer chapters you built lib/env.ts with @t3-oss/env-nextjs and Zod: a typed schema that validates every environment variable at build time and gives you a single import that’s guaranteed populated. Adding the email key is one line in the server block.

export const env = createEnv({
server: {
DATABASE_URL: z.url(),
DATABASE_URL_UNPOOLED: z.url(),
RESEND_API_KEY: z.string().min(1),
},
// …client block, runtimeEnv mapping
});

The payoff is the reason this pattern exists: with the key in the schema, a production build refuses to boot if RESEND_API_KEY is missing or empty. You can’t accidentally ship an email feature with no credential and discover it when the first user’s verification email silently never arrives. The failure moves from a 4am production incident to a build-time error you see before you deploy.

You need two packages:

Terminal window
pnpm add resend react-email

resend is the SDK you’ll call. You pull in react-email now because Resend renders your email content from a React component, and that package is what lets you build that component. As of React Email 6 every primitive ships from the one react-email package, and the old @react-email/components is deprecated. The actual template work is the next chapter; today a one-line placeholder component stands in so the wiring is honest.

Everything that sends email in your app will go through one module, lib/email.ts. Before the code, there’s one architectural decision that’s easy to get wrong.

A careful engineer who sees a third-party SDK is tempted to hide it behind a generic interface: an EmailProvider abstraction with a send method, so you “could swap Resend out later.” Resist that here. This wrapper is a thin convenience layer, not an adapter. It is one of a small set of sanctioned SDK carve-outs that live in lib/ and call their vendor directly: Resend, the background-job runner, and the object store. The course’s fifth architectural principle names this explicitly. These SDKs are used directly, not wrapped behind a generic interface, because the cost of the abstraction outweighs a swap you will almost certainly never make. A premature EmailProvider interface buys you nothing and taxes every future change. The wrapper exists for three concrete, present-day jobs, not to pretend Resend is interchangeable: a default from address, the canonical Result return shape, and a reserved spot for the suppression check that lands two lessons from now.

Here is the whole module. Walk through it one part at a time.

import 'server-only';
import type { ReactNode } from 'react';
import { Resend } from 'resend';
import { env } from '@/env';
import { ok, err, type Result } from '@/lib/result';
const resend = new Resend(env.RESEND_API_KEY);
const DEFAULT_FROM = 'YourApp <noreply@send.yourapp.com>';
type SendEmailInput = {
to: string;
subject: string;
react: ReactNode;
replyTo?: string;
idempotencyKey?: string;
};
export async function sendEmail(
input: SendEmailInput,
): Promise<Result<{ id: string }>> {
// The suppression check lands here — see the suppression-list lesson.
const { data, error } = await resend.emails.send(
{
from: DEFAULT_FROM,
to: [input.to],
subject: input.subject,
react: input.react,
replyTo: input.replyTo,
},
input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined,
);
if (error || !data) return err('internal', 'Could not send email.');
return ok({ id: data.id });
}

This module holds your secret API key, so the first line makes it a build error if anything client-side ever imports it. The key can never leak into a browser bundle. The client itself is constructed once at module load, a single shared singleton reused across every send rather than a fresh client per call.

import 'server-only';
import type { ReactNode } from 'react';
import { Resend } from 'resend';
import { env } from '@/env';
import { ok, err, type Result } from '@/lib/result';
const resend = new Resend(env.RESEND_API_KEY);
const DEFAULT_FROM = 'YourApp <noreply@send.yourapp.com>';
type SendEmailInput = {
to: string;
subject: string;
react: ReactNode;
replyTo?: string;
idempotencyKey?: string;
};
export async function sendEmail(
input: SendEmailInput,
): Promise<Result<{ id: string }>> {
// The suppression check lands here — see the suppression-list lesson.
const { data, error } = await resend.emails.send(
{
from: DEFAULT_FROM,
to: [input.to],
subject: input.subject,
react: input.react,
replyTo: input.replyTo,
},
input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined,
);
if (error || !data) return err('internal', 'Could not send email.');
return ok({ id: data.id });
}

The default sender identity, defined in exactly one place. Every send uses it unless a caller overrides it, so the app’s from address has a single source of truth. We’ll dissect the anatomy of that string in a moment.

import 'server-only';
import type { ReactNode } from 'react';
import { Resend } from 'resend';
import { env } from '@/env';
import { ok, err, type Result } from '@/lib/result';
const resend = new Resend(env.RESEND_API_KEY);
const DEFAULT_FROM = 'YourApp <noreply@send.yourapp.com>';
type SendEmailInput = {
to: string;
subject: string;
react: ReactNode;
replyTo?: string;
idempotencyKey?: string;
};
export async function sendEmail(
input: SendEmailInput,
): Promise<Result<{ id: string }>> {
// The suppression check lands here — see the suppression-list lesson.
const { data, error } = await resend.emails.send(
{
from: DEFAULT_FROM,
to: [input.to],
subject: input.subject,
react: input.react,
replyTo: input.replyTo,
},
input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined,
);
if (error || !data) return err('internal', 'Could not send email.');
return ok({ id: data.id });
}

The wrapper takes a single options object, not a pile of positional arguments. Past two parameters, an object is the convention, and it makes call sites self-documenting. Note that react is a ReactNode: the email body is a component, the subject is a plain string, and replyTo and idempotencyKey are optional fields a caller opts into.

import 'server-only';
import type { ReactNode } from 'react';
import { Resend } from 'resend';
import { env } from '@/env';
import { ok, err, type Result } from '@/lib/result';
const resend = new Resend(env.RESEND_API_KEY);
const DEFAULT_FROM = 'YourApp <noreply@send.yourapp.com>';
type SendEmailInput = {
to: string;
subject: string;
react: ReactNode;
replyTo?: string;
idempotencyKey?: string;
};
export async function sendEmail(
input: SendEmailInput,
): Promise<Result<{ id: string }>> {
// The suppression check lands here — see the suppression-list lesson.
const { data, error } = await resend.emails.send(
{
from: DEFAULT_FROM,
to: [input.to],
subject: input.subject,
react: input.react,
replyTo: input.replyTo,
},
input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined,
);
if (error || !data) return err('internal', 'Could not send email.');
return ok({ id: data.id });
}

A deliberate seam, not dead code. Two lessons from now this comment becomes a check that asks “has this address bounced or complained?” before any send goes out. It lives here, in the one wrapper every send flows through, because a check at the chokepoint can’t be forgotten by an individual caller.

import 'server-only';
import type { ReactNode } from 'react';
import { Resend } from 'resend';
import { env } from '@/env';
import { ok, err, type Result } from '@/lib/result';
const resend = new Resend(env.RESEND_API_KEY);
const DEFAULT_FROM = 'YourApp <noreply@send.yourapp.com>';
type SendEmailInput = {
to: string;
subject: string;
react: ReactNode;
replyTo?: string;
idempotencyKey?: string;
};
export async function sendEmail(
input: SendEmailInput,
): Promise<Result<{ id: string }>> {
// The suppression check lands here — see the suppression-list lesson.
const { data, error } = await resend.emails.send(
{
from: DEFAULT_FROM,
to: [input.to],
subject: input.subject,
react: input.react,
replyTo: input.replyTo,
},
input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined,
);
if (error || !data) return err('internal', 'Could not send email.');
return ok({ id: data.id });
}

The actual send. to is an array because Resend accepts multiple recipients. The second argument is the options bag, and the only thing we pass through it is the idempotencyKey, conditionally, so a retried send never produces two emails. That key is the subject of this lesson’s last section.

import 'server-only';
import type { ReactNode } from 'react';
import { Resend } from 'resend';
import { env } from '@/env';
import { ok, err, type Result } from '@/lib/result';
const resend = new Resend(env.RESEND_API_KEY);
const DEFAULT_FROM = 'YourApp <noreply@send.yourapp.com>';
type SendEmailInput = {
to: string;
subject: string;
react: ReactNode;
replyTo?: string;
idempotencyKey?: string;
};
export async function sendEmail(
input: SendEmailInput,
): Promise<Result<{ id: string }>> {
// The suppression check lands here — see the suppression-list lesson.
const { data, error } = await resend.emails.send(
{
from: DEFAULT_FROM,
to: [input.to],
subject: input.subject,
react: input.react,
replyTo: input.replyTo,
},
input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : undefined,
);
if (error || !data) return err('internal', 'Could not send email.');
return ok({ id: data.id });
}

The senior reflex with this SDK. Resend returns { data, error } and does not throw on a failed send. A caller that only reads data and ignores error will treat a silent rate-limit failure as a success. Always inspect error first, map it into the Result your action layer already understands, and only trust data once you’ve ruled the error out.

1 / 1

That { data, error } return is worth dwelling on, because it’s the single most common way a send goes wrong silently. The Resend SDK does not throw when a send fails. A validation error, a rate-limit, or an unverified from all come back as a populated error field alongside a null data. If your code does const { data } = await resend.emails.send(...) and walks away, every one of those failures looks exactly like success. Destructure both, and check error first. The wrapper does this once so no caller ever has to remember.

Time to actually send something. To see what the wrapper is doing, look first at the bare SDK call it wraps: the native surface, with nothing of yours around it.

const { data, error } = await resend.emails.send(
{
from: 'YourApp <noreply@send.yourapp.com>',
to: ['delivered@resend.dev'],
subject: 'Welcome to YourApp',
react: <WelcomeEmail name="Ada" />,
},
{ idempotencyKey: 'welcome-user/usr_123' },
);

Notice the recipient: delivered@resend.dev. Resend runs a few special test addresses that simulate outcomes without a real mailbox. delivered@resend.dev always reports a successful delivery, which is exactly what you want for a first verify: a clean success with nothing to set up. Its two siblings, bounced@resend.dev and complained@resend.dev, simulate a hard bounce and a spam complaint, and you’ll use those much later when you build the webhook handler that reacts to bounces and complaints. For today, you only need delivered@.

The react prop needs something too. Until the next chapter builds real templates, a one-line placeholder keeps the call honest:

// Placeholder — the next chapter replaces this with a real React Email template.
const WelcomeEmail = ({ name }: { name: string }) => <p>Welcome, {name}!</p>;

It’s a plain server-rendered component: no state, no effects, just markup. Why the email component can’t use client hooks is a next-chapter detail; for now, treat it as a constraint and move on.

Now wire it through your wrapper. From a small Server Action, or a one-off script if you’d rather not build a form yet, the call uses the familiar Result shape:

const result = await sendEmail({
to: 'delivered@resend.dev',
subject: 'Welcome to YourApp',
react: <WelcomeEmail name="Ada" />,
idempotencyKey: 'welcome-user/usr_123',
});
if (!result.ok) {
// surface result.error.userMessage to the caller
}

Run it. Three things tell you it worked: the call returns ok with a data.id, the send shows up in your Resend dashboard with a delivered status, and delivered@resend.dev reports the message as delivered. That is the lesson’s done condition: a real email, through your own verified domain, through your own wrapper, returning your own Result.

The from line is a UX and reputation decision

Section titled “The from line is a UX and reputation decision”

You’ve now sent from YourApp <noreply@send.yourapp.com> twice without me explaining the string. It deserves a closer look, because the from line is read by two audiences, the human recipient and the receiving mailbox provider, and a careless one costs you on both.

It has three parts, and each is a decision:

YourApp  < noreply @ send.yourapp.com >
Display name what the recipient reads in their inbox list.
Local part signals the email's intent; routes the user's filters.
Verified subdomain the mailbox must live here, or the send is rejected.
Three parts, three decisions. The display name is UX, the local part is intent, the subdomain is the deliverability requirement.

The display name (YourApp) is pure UX. It’s the bold text the recipient sees in their inbox list, so make it your product’s name, not a machine string. The verified subdomain (send.yourapp.com) is the hard requirement: the mailbox has to live on a domain you’ve verified, or Resend rejects the send outright. That leaves the local part, the bit before the @, which is where a small senior reflex lives.

Name the local part for the intent the user reads off the from line, not for the system that happens to send the mail. Addresses like noreply@, auth@, billing@, and security@ are signals. A user who sees security@ knows to pay attention, and their inbox filters can route billing@ to a folder. They are reading meaning from those few characters, so spend them on meaning. An address like auth-service-prod@ leaks your internal architecture into the user’s inbox and signals nothing useful, which makes it a code smell. The full address-convention table is the lesson after next; today you just need enough to write an honest from.

The second reflex is about replies. A from: noreply@… that silently swallows every reply quietly disappoints the user, because someone will hit reply on a receipt or a security alert, and their message vanishing into a black hole reads as a broken product. It’s also a faint negative signal to mailbox providers, which increasingly reward senders who behave like they can be reached. The fix is the reply-to header: keep the bot identity in from, but point replyTo at a mailbox a human actually monitors.

await sendEmail({
to: 'delivered@resend.dev',
replyTo: 'support@yourapp.com',
// …from defaults to the noreply identity in the wrapper
subject: 'Your receipt',
react: <Receipt /* … */ />,
});

Now the inbox shows noreply@send.yourapp.com, clearly automated, but a reply lands in support@, where someone reads it. The user gets the honest signal and a way to reach a person.

Idempotency, rate limits, and the three environments

Section titled “Idempotency, rate limits, and the three environments”

Three send-time disciplines close out the lesson. They share a theme, the production stakes of a send that gets retried, duplicated, or fired in the wrong environment, so take them together.

This reflex matters more than any other in the lesson. Distributed systems retry. A Server Action times out and the framework retries it, a user clicks “resend” before the first attempt finished, or a webhook gets redelivered. Without protection, each retry is another email: two welcome messages, two password resets, two receipts for one charge. The fix is an idempotency key, a stable identifier you attach to the send so Resend recognizes a repeat and sends the email only once.

Resend takes the key as the second argument’s idempotencyKey option (max 256 characters, remembered for 24 hours). The detail that matters is the shape of the key. Don’t generate a fresh random value per attempt, because that defeats the entire purpose: each retry would carry a new key and look like a new event. Build the key from the stable id the event already has, in Resend’s recommended <event-type>/<entity-id> form:

const welcomeKey = `welcome-user/${userId}`;
const resetKey = `password-reset/${requestId}`;
const receiptKey = `invoice-receipt/${invoiceId}`;

The pattern is the same each time: an event-type prefix, then the stable id the event already carries. Now every retry of “the welcome email for user 123” carries the identical key welcome-user/123, and Resend collapses them to one send. The <event-type>/ prefix namespaces the key so a user id and an invoice id can never collide. This is the same idempotency thread that runs through webhooks and background jobs later in the course; here it’s the email-specific instance of a pattern you’ll see again and again.

Resend’s default limit is 5 requests per second per team, shared across all of that team’s API keys, and raisable on request once you’re an established sender. Don’t let the number alarm you. A Server Action that sends one email per request will never come near it, since five sends a second is a lot of individual user actions. The limit only matters on a bulk path, such as inviting a whole team at once or sending a digest to thousands of users. For those, Resend offers a batch endpoint that sends up to 100 emails in a single call, which counts as one request against the limit, and you’d pair it with the same idempotency key discipline. Building that batch path is a later unit; today, just know the number exists and that bulk has its own tool.

Where you send depends on where you’re running.

  • Dev. Send to your own inbox, or to the *@resend.dev test addresses. Never to real users from your laptop.
  • Preview. Every pull-request preview deploy can run your Server Actions, which means a careless email action could reach real users from a throwaway branch. Gate sending in preview behind a flag or a recipient allowlist so that can’t happen.
  • Production. The verified domain is required, and thanks to the env slot you wired earlier, the build won’t even boot without RESEND_API_KEY. The discipline you set up at the start of the lesson is what makes production safe by default.

One scenario pulls these threads together. Think it through before you check the answer.

A password-reset Server Action takes too long and times out. The user, seeing nothing happen, clicks “resend” — and meanwhile the framework retries the original call on its own. Three send attempts are now in flight for one reset request. What stops the user from getting three reset emails?

Requesting a higher rate limit from Resend
Disabling retries on the Server Action entirely
Attaching the reset request’s id to every attempt as the idempotencyKey, so Resend collapses the duplicates into one send
Checking the suppression list before sending

Resend’s own documentation is the source of truth for the API surface, and it stays current as the dashboard moves. These cover everything this lesson touched.

You now have the infrastructure every later send depends on: a verified domain, a per-environment key, a typed env slot, and a lib/email.ts wrapper that speaks Result. The next lesson opens up the DNS records you copied without reading, SPF, DKIM, and DMARC, and explains why, in 2026, an email that isn’t authenticated by those three protocols is essentially undeliverable.